Plankton DNA study · Assam, India

Reading the life of two lakes from a bottle of water

The tiny plants and animals drifting in a lake are too small and too many to count by eye. Instead, we read the DNA they leave in the water. This page follows that DNA from the sequencing machine to a picture of what lives in each lake and what it tells us — one step at a time.

Chandubi BeelSample W1 · a natural lake at the foot of the Garo Hills, relatively undisturbed
Deepor BeelSample W2 · a Ramsar wetland on the edge of Guwahati, under urban pressure
Lake water→DNA→Sequencer→ Check→Trim→Remove typos→Remove fakes→Name→Look up unknowns→Diversity→Ecology→Proteins→Paper files→Microscopy check
  1. Before the computer

    Water, DNA and a sequencing machine

    Every living thing sheds DNA into the water around it — skin cells, mucus, waste, whole tiny bodies. A single bottle of lake water therefore carries a genetic trace of the community living there. This approach is called environmental DNA (eDNA).

    Deepor Beel lies right beside Guwahati’s municipal solid-waste dumping ground, while Chandubi Beel has no comparable pressure — so comparing the two shows what that pressure does to the life in the water. No water chemistry was measured; instead, the organisms themselves serve as indicators of water condition (Steps 9 and 11).

    Water was collected on 29 March 2026 — Chandubi Beel at 25.881015° N, 91.422898° E and Deepor Beel at 26.10953° N, 91.70818° E — the DNA extracted, and two short barcode genes copied millions of times and read by an Illumina MiSeq sequencer at Eurofins Genomics:

    18S rRNA

    A barcode shared by almost all complex life. It catches microscopic algae, protists and fungi as well as small animals — the base of the food web.

    COI

    The classic “animal barcode”. It catches zooplankton — rotifers, water fleas, copepods, mites and insect larvae.

    The machine returns its output as text files of DNA letters (A, C, G, T). Everything below is about turning those raw files into a clean, trustworthy list of the distinct DNA signatures present in each lake.

  2. Step 1

    Checking the raw data

    In plain wordsBefore using any data you check it arrived intact and is of good quality — like checking a set of photos for damage and blur before choosing which to print.

    Each file’s digital fingerprint (checksum) was compared to the one supplied by the lab: all eight match, so nothing was corrupted in transfer. Each DNA fragment is read from both ends, giving two 301-letter reads per fragment. The machine’s confidence is high for most of the read but fades towards the end, especially on the second read — so the tail ends will be trimmed in step 3.

    8 / 8files intact
    620,380DNA fragments read
    2 × 301letters per fragment
    <b>How to read this:</b> left to right is position along the read; height is the machine’s confidence (above 30 = fewer than 1 error in 1,000 letters). The green line holds up on the left panels (first read) but sinks on the right (second read) after about letter 230.
    How to read this: left to right is position along the read; height is the machine’s confidence (above 30 = fewer than 1 error in 1,000 letters). The green line holds up on the left panels (first read) but sinks on the right (second read) after about letter 230.
    For specialists — scripts, parameters & full tables

    Checksums vs vendor MD5SUM_Raw.txt; FastQC + MultiQC; per-file read statistics; DADA2 quality profiles (50,000 reads per file). All files come from one MiSeq run, M04635:72:000000000-MDYKK.

    Script scripts/01_qc.sh
    #!/usr/bin/env bash
    # Step 1 — Raw read QC: checksum verification, FastQC + MultiQC, per-file read stats,
    # and DADA2 quality profiles. Inputs: NEW/<marker>/<site>/*.fastq.gz
    set -euo pipefail
    cd "$(dirname "$0")/.."
    
    OUT=results/01_qc
    mkdir -p "$OUT" work/01_fastqc docs/img
    
    # 1a. MD5 verification against the vendor checksum files
    : > "$OUT/md5_check.tsv"
    for m in 18S COI; do
      while read -r hash file; do
        path=$(ls NEW/"$m"/*/"$file")
        got=$(md5sum < "$path" | cut -d' ' -f1)
        status=$([ "$got" = "$hash" ] && echo OK || echo FAIL)
        printf '%s\t%s\t%s\n' "$m" "$path" "$status" >> "$OUT/md5_check.tsv"
      done < NEW/"$m"/MD5SUM_Raw.txt
    done
    
    # 1b. FastQC on every raw file, summarised with MultiQC
    find NEW -name '*.fastq.gz' -print0 | xargs -0 fastqc -q -t 8 --outdir work/01_fastqc
    multiqc -q -f -o "$OUT/multiqc" -n multiqc_raw.html work/01_fastqc
    
    # 1c. Read count / length / GC / mean quality per file
    printf 'marker\tsite\tsample\tread\treads\tmean_len\tgc_pct\tmean_q\n' > "$OUT/read_stats.tsv"
    for f in NEW/*/*/*.fastq.gz; do
      marker=$(echo "$f" | cut -d/ -f2); site=$(echo "$f" | cut -d/ -f3)
      base=$(basename "$f" .fastq.gz); sample=${base%_R*}; read=${base##*_}
      zcat "$f" | awk -v m="$marker" -v s="$site" -v sm="$sample" -v r="$read" '
        BEGIN { for (i = 33; i < 127; i++) ord[sprintf("%c", i)] = i - 33 }
        NR % 4 == 2 { n++; len += length($0); gc += gsub(/[GCgc]/, "") }
        NR % 4 == 0 { for (i = 1; i <= length($0); i++) q += ord[substr($0, i, 1)]; qn += length($0) }
        END { printf "%s\t%s\t%s\t%s\t%d\t%.1f\t%.1f\t%.1f\n", m, s, sm, r, n, len/n, 100*gc/len, q/qn }' \
        >> "$OUT/read_stats.tsv"
    done
    
    # 1d. Per-position quality profiles (DADA2)
    Rscript scripts/01_quality_profiles.R
    

    View on GitHub

    Script scripts/01_quality_profiles.R
    # Step 1d — DADA2 per-position quality profiles of the raw reads (R1 and R2, both sites).
    suppressPackageStartupMessages({ library(dada2); library(ggplot2) })
    
    for (marker in c("18S", "COI")) {
      files <- sort(list.files(file.path("NEW", marker), pattern = "fastq.gz$",
                               recursive = TRUE, full.names = TRUE))
      p <- plotQualityProfile(files, n = 50000) +
        ggtitle(sprintf("%s raw reads — per-position quality", marker)) +
        theme(plot.title = element_text(size = 12))
      ggsave(sprintf("docs/img/01_quality_%s.png", marker), p, width = 10, height = 7, dpi = 120)
    }
    

    View on GitHub

    18SNEW/18S/Chandubi/W1_R1.fastq.gzOK
    18SNEW/18S/Chandubi/W1_R2.fastq.gzOK
    18SNEW/18S/Dipor Beel/W2_R1.fastq.gzOK
    18SNEW/18S/Dipor Beel/W2_R2.fastq.gzOK
    COINEW/COI/Chandubi/W1_R1.fastq.gzOK
    COINEW/COI/Chandubi/W1_R2.fastq.gzOK
    COINEW/COI/Dipor Beel/W2_R1.fastq.gzOK
    COINEW/COI/Dipor Beel/W2_R2.fastq.gzOK

    results/01_qc/md5_check.tsv

    markersitesamplereadreadsmean lengthGC %mean Q
    18SChandubiW1R1166,108301.044.434.4
    18SChandubiW1R2166,108301.043.128.5
    18SDipor BeelW2R1143,035301.047.234.3
    18SDipor BeelW2R2143,035301.044.728.5
    COIChandubiW1R1167,886301.038.634.8
    COIChandubiW1R2167,886301.038.829.4
    COIDipor BeelW2R1143,351301.039.034.9
    COIDipor BeelW2R2143,351301.039.129.2

    results/01_qc/read_stats.tsv

    COI quality profiles.
    COI quality profiles.

    Full MultiQC report →

  3. Step 2

    Removing the copying labels

    In plain wordsTo copy a barcode gene, the lab uses short “starter” pieces of DNA called primers — think of them as address labels stuck to each end. They are not part of the organism’s DNA, so they are cut off. Any fragment missing its labels was copied by mistake and is thrown away.

    Nearly nine in ten fragments carried both labels exactly where expected — a sign the lab work went well. The primers were identified by reading the data itself, not taken on trust.

    89%fragments kept
    2primer pairs found
    ~280letters left per read
    Blue: fragments that carried both labels and were kept. Grey: discarded. Both lakes and both barcodes behave alike.
    Blue: fragments that carried both labels and were kept. Grey: discarded. Both lakes and both barcodes behave alike.
    For specialists — scripts, parameters & full tables
    MarkerForwardReverseReference
    18S V4E572F CYGCGGTAATTCCAGCTCE1009R AYGGTATCTRWTCRTCTTYGComeau et al. 2011
    COImlCOIintF GGWACWGGWTGAACWGTWTAYCCYCCjgHCO2198 TANACYTCNGGRTGNCCRAARAAYCALeray et al. 2013

    Cutadapt linked adapters: 5′ primer anchored and required, read-through primer optional; max error 0.15; untrimmed pairs discarded.

    Script scripts/02_primer_removal.sh
    #!/usr/bin/env bash
    # Step 2 — Primer removal with Cutadapt.
    # Each read must start with its primer (anchored 5'); pairs where either primer is
    # missing are discarded. If a read runs through into the opposite primer, that
    # (reverse-complemented) primer and everything after it is trimmed too.
    #   18S V4 : E572F  CYGCGGTAATTCCAGCTC     / E1009R   AYGGTATCTRWTCRTCTTYG   (Comeau et al. 2011)
    #   COI    : mlCOIintF GGWACWGGWTGAACWGTWTAYCCYCC / jgHCO2198 TAIACYTCIGGRTGICCRAARAAYCA (Leray et al. 2013; I -> N)
    set -euo pipefail
    cd "$(dirname "$0")/.."
    
    OUT=results/02_primer_removal
    mkdir -p "$OUT" work/02_trimmed
    
    revcomp() { echo "$1" | rev | tr ACGTRYKMBVDHN TGCAYRMKVBHDN; }
    
    declare -A FWD=( [18S]=CYGCGGTAATTCCAGCTC [COI]=GGWACWGGWTGAACWGTWTAYCCYCC )
    declare -A REV=( [18S]=AYGGTATCTRWTCRTCTTYG [COI]=TANACYTCNGGRTGNCCRAARAAYCA )
    
    printf 'marker\tsample\tpairs_in\tpairs_with_both_primers\tpct_kept\n' > "$OUT/cutadapt_summary.tsv"
    for m in 18S COI; do
      F=${FWD[$m]}; R=${REV[$m]}
      mkdir -p "work/02_trimmed/$m"
      for r1 in NEW/"$m"/*/*_R1.fastq.gz; do
        s=$(basename "$r1" _R1.fastq.gz); r2=${r1/_R1/_R2}
        cutadapt -j 8 --discard-untrimmed -e 0.15 --minimum-length 50 \
          -g "^${F};required...$(revcomp "$R");optional" -G "^${R};required...$(revcomp "$F");optional" \
          --json "$OUT/${m}_${s}.cutadapt.json" \
          -o "work/02_trimmed/$m/${s}_R1.fastq.gz" -p "work/02_trimmed/$m/${s}_R2.fastq.gz" \
          "$r1" "$r2" > "$OUT/${m}_${s}.cutadapt.log"
        python3 - "$OUT/${m}_${s}.cutadapt.json" "$m" "$s" >> "$OUT/cutadapt_summary.tsv" <<'PY'
    import json, sys
    j = json.load(open(sys.argv[1]))["read_counts"]
    print(f"{sys.argv[2]}\t{sys.argv[3]}\t{j['input']}\t{j['output']}\t{100*j['output']/j['input']:.1f}")
    PY
      done
    done
    
    # Post-trim length distribution (input to choosing DADA2 truncation lengths)
    printf 'marker\tsample\tread\tlength\tcount\n' > "$OUT/trimmed_lengths.tsv"
    for f in work/02_trimmed/*/*.fastq.gz; do
      m=$(basename "$(dirname "$f")"); b=$(basename "$f" .fastq.gz)
      zcat "$f" | awk -v m="$m" -v s="${b%_R*}" -v r="${b##*_}" \
        'NR%4==2{c[length($0)]++} END{for(l in c) printf "%s\t%s\t%s\t%d\t%d\n", m, s, r, l, c[l]}' \
        >> "$OUT/trimmed_lengths.tsv"
    done
    Rscript scripts/02_plot_lengths.R
    

    View on GitHub

    Script scripts/02_plot_lengths.R
    # Step 2 — Plot read-retention after primer removal and trimmed read-length distribution.
    suppressPackageStartupMessages({ library(ggplot2); library(data.table) })
    
    s <- fread("results/02_primer_removal/cutadapt_summary.tsv")
    s[, discarded := pairs_in - pairs_with_both_primers]
    long <- melt(s, id.vars = c("marker", "sample"),
                 measure.vars = c("pairs_with_both_primers", "discarded"),
                 variable.name = "fate", value.name = "pairs")
    long[, fate := factor(fate, levels = c("discarded", "pairs_with_both_primers"),
                          labels = c("discarded (primer not found)", "kept"))]
    p1 <- ggplot(long, aes(sample, pairs, fill = fate)) +
      geom_col(width = 0.6) + facet_wrap(~marker) +
      scale_fill_manual(values = c("#d9d9d9", "#2b6c8f")) +
      scale_y_continuous(labels = scales::comma) +
      labs(x = NULL, y = "Read pairs", fill = NULL, title = "Read pairs kept after primer removal") +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom")
    ggsave("docs/img/02_primer_retention.png", p1, width = 7, height = 4.5, dpi = 120)
    
    l <- fread("results/02_primer_removal/trimmed_lengths.tsv")
    p2 <- ggplot(l[length >= 150], aes(length, count, colour = sample)) +
      geom_line() + facet_grid(read ~ marker, scales = "free_y") +
      scale_y_log10(labels = scales::comma) +
      scale_colour_manual(values = c(W1 = "#2b6c8f", W2 = "#c2662d"),
                          labels = c(W1 = "Chandubi (W1)", W2 = "Deepor Beel (W2)")) +
      labs(x = "Read length after primer removal (bp)", y = "Reads (log scale)", colour = NULL,
           title = "Trimmed read-length distribution") +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom")
    ggsave("docs/img/02_trimmed_lengths.png", p2, width = 8, height = 5, dpi = 120)
    

    View on GitHub

    markersamplepairs inkept% kept
    18SW1166,108145,81387.8
    18SW2143,035128,22289.6
    COIW1167,886148,98088.7
    COIW2143,351127,41888.9

    results/02_primer_removal/cutadapt_summary.tsv

    Read lengths after primer removal (log scale).
    Read lengths after primer removal (log scale).
  4. Step 3

    Separating real DNA from typos

    In plain wordsSequencers make occasional typos. If you counted every slightly different spelling as a new organism you would invent thousands of species that don’t exist. This step learns the machine’s typo habits from the data itself, then decides which sequences are real and which are just misspellings of them.

    What survives is a list of ASVs (amplicon sequence variants) — each one an exact, unique DNA signature, roughly “one distinct kind of organism”. The two reads of each fragment are then joined into one full sequence, and chimeras — artificial sequences glued together from two different organisms during copying — are removed.

    Importantly, both lakes were processed together, so the same organism gets the same signature in both lakes and they can be compared fairly.

    1,03518S signatures
    376COI signatures
    9.32%18S reads were chimeras
    <b>The typo model.</b> Each panel is one kind of substitution (e.g. A read as C). Dots are observed error rates; the black line is the model learned from the data. They agree, and errors fall as the machine’s confidence (x-axis) rises — the model can be trusted.
    The typo model. Each panel is one kind of substitution (e.g. A read as C). Dots are observed error rates; the black line is the model learned from the data. They agree, and errors fall as the machine’s confidence (x-axis) rises — the model can be trusted.
    For specialists — scripts, parameters & full tables
    Parameter18SCOI
    truncLen (R1, R2)270, 210250, 200
    maxEE (R1, R2)2, 42, 4
    Denoisingpooled across both sites (pool = TRUE)
    MergingminOverlap 12, maxMismatch 0
    ASVs before → after chimera removal2043 → 1035481 → 376
    Script scripts/03_dada2.R
    # Step 3 — DADA2: filter/trim, learn errors, denoise both sites JOINTLY (pool = TRUE),
    # merge pairs, remove chimeras. One run per marker so ASVs are comparable across sites.
    # Usage: Rscript scripts/03_dada2.R <marker> <truncF> <truncR> <maxEE_F> <maxEE_R>
    suppressPackageStartupMessages({ library(dada2); library(ggplot2); library(data.table) })
    
    args <- commandArgs(trailingOnly = TRUE)
    marker <- args[1]
    truncLen <- as.integer(args[2:3])
    maxEE <- as.numeric(args[4:5])
    set.seed(2026)
    
    inDir <- file.path("work/02_trimmed", marker)
    filtDir <- file.path("work/03_filtered", marker)
    out <- file.path("results/03_dada2", marker)
    dir.create(filtDir, recursive = TRUE, showWarnings = FALSE)
    dir.create(out, recursive = TRUE, showWarnings = FALSE)
    
    fnF <- sort(list.files(inDir, "_R1.fastq.gz$", full.names = TRUE))
    fnR <- sort(list.files(inDir, "_R2.fastq.gz$", full.names = TRUE))
    samples <- sub("_R1.fastq.gz$", "", basename(fnF))
    filtF <- file.path(filtDir, paste0(samples, "_F.fastq.gz"))
    filtR <- file.path(filtDir, paste0(samples, "_R.fastq.gz"))
    names(filtF) <- names(filtR) <- samples
    
    # 3a. Quality filtering
    filt <- filterAndTrim(fnF, filtF, fnR, filtR, truncLen = truncLen, maxEE = maxEE,
                          maxN = 0, truncQ = 2, rm.phix = TRUE, compress = TRUE, multithread = TRUE)
    
    # 3b. Error models
    errF <- learnErrors(filtF, multithread = TRUE, randomize = TRUE, verbose = FALSE)
    errR <- learnErrors(filtR, multithread = TRUE, randomize = TRUE, verbose = FALSE)
    for (d in list(list(errF, "R1"), list(errR, "R2"))) {
      p <- plotErrors(d[[1]], nominalQ = TRUE) +
        ggtitle(sprintf("%s %s — learned error rates (points) vs fitted model (black line)", marker, d[[2]]))
      ggsave(sprintf("docs/img/03_errors_%s_%s.png", marker, d[[2]]), p, width = 9, height = 7, dpi = 110)
    }
    
    # 3c. Joint denoising of both sites
    dadaF <- dada(filtF, err = errF, pool = TRUE, multithread = TRUE, verbose = FALSE)
    dadaR <- dada(filtR, err = errR, pool = TRUE, multithread = TRUE, verbose = FALSE)
    
    # 3d. Merge pairs, build table, remove chimeras
    mergers <- mergePairs(dadaF, filtF, dadaR, filtR, minOverlap = 12, maxMismatch = 0)
    seqtab <- makeSequenceTable(mergers)
    nochim <- removeBimeraDenovo(seqtab, method = "consensus", multithread = TRUE)
    
    # 3e. Outputs: ASV table, ASV FASTA, length table, read tracking
    getN <- function(x) sum(getUniques(x))
    track <- data.table(marker = marker, sample = samples,
                        input = filt[, 1], filtered = filt[, 2],
                        denoisedF = sapply(dadaF, getN), denoisedR = sapply(dadaR, getN),
                        merged = sapply(mergers, getN), nonchim = rowSums(nochim))
    fwrite(track, file.path(out, "track.tsv"), sep = "\t")
    
    asv_seqs <- colnames(nochim)
    asv_ids <- sprintf("%s_ASV%04d", marker, seq_along(asv_seqs))
    counts <- data.table(asv = asv_ids, t(nochim))
    setnames(counts, c("asv", rownames(nochim)))
    counts[, length := nchar(asv_seqs)][, sequence := asv_seqs]
    fwrite(counts, file.path(out, "asv_table.tsv"), sep = "\t")
    writeLines(paste0(">", asv_ids, "\n", asv_seqs), file.path(out, "asv_seqs.fasta"))
    
    lens <- data.table(length = nchar(colnames(seqtab)), reads = colSums(seqtab),
                       chimera = !colnames(seqtab) %in% asv_seqs)
    fwrite(lens[, .(asvs = .N, reads = sum(reads)), by = .(length, chimera)][order(length)],
           file.path(out, "merged_lengths.tsv"), sep = "\t")
    
    summary <- list(marker = marker, truncLen = truncLen, maxEE = maxEE,
                    asvs_before_chimera = ncol(seqtab), asvs_final = ncol(nochim),
                    chimeric_read_pct = round(100 * (1 - sum(nochim) / sum(seqtab)), 2))
    jsonlite::write_json(summary, file.path(out, "summary.json"), auto_unbox = TRUE, pretty = TRUE)
    print(track); str(summary)
    

    View on GitHub

    18S read tracking

    markersampleinputfiltereddenoisedFdenoisedRmergednonchim
    18SW1145,813115,951114,021113,934104,11793,012
    18SW2128,222101,02799,06499,78588,76881,898

    results/03_dada2/18S/track.tsv

    COI read tracking

    markersampleinputfiltereddenoisedFdenoisedRmergednonchim
    COIW1148,980125,051124,414124,337123,126117,653
    COIW2127,418107,049106,932106,813106,300102,969

    results/03_dada2/COI/track.tsv

    18S R1
    18S R1
    18S R2
    18S R2
    COI R1
    COI R1
    COI R2
    COI R2

    Output files

  5. Step 4

    Weeding out fakes

    In plain wordsThe COI barcode is a recipe for a protein. A genuine recipe reads like a sentence with no “STOP” in the middle. Sequences that contain a stop, or whose length breaks the three-letter rhythm of the code, are broken copies (old “ghost” genes or errors) and are removed. For 18S, sequences far too short or too long to be the real barcode are removed.

    Very little had to go — the data was already clean after step 3.

    31COI fakes removed
    5318S off-length removed
    1,327signatures kept
    Each dot is one DNA signature: how long it is (x) and how common (y, log scale). Blue were kept. The kept COI signatures line up neatly at 313 and 316 letters — exactly the expected size, differing by one three-letter “word”.
    Each dot is one DNA signature: how long it is (x) and how common (y, log scale). Blue were kept. The kept COI signatures line up neatly at 313 and 316 letters — exactly the expected size, differing by one three-letter “word”.
    For specialists — scripts, parameters & full tables

    COI: length must differ from 313 bp by whole codons (±10) and translate stop-free under NCBI genetic code 5 (invertebrate mitochondrial); coi coding frame: +2. 18S: 350–480 bp.

    Script scripts/04_asv_curation.R
    # Step 4 — ASV curation.
    #  COI : a real COI ASV is protein-coding. Keep ASVs whose length differs from the 313 bp
    #        Leray fragment by whole codons (±10 codons) AND translate with no stop codon
    #        under the invertebrate mitochondrial code (NCBI table 5). Others are likely
    #        NUMTs / pseudogenes or artefacts.
    #  18S : keep ASVs within the expected V4 (E572F–E1009R) length window, 350–480 bp.
    suppressPackageStartupMessages({ library(Biostrings); library(data.table); library(ggplot2) })
    
    out <- "results/04_curation"
    dir.create(out, recursive = TRUE, showWarnings = FALSE)
    read_asv <- function(m) fread(file.path("results/03_dada2", m, "asv_table.tsv"))
    
    # ---- COI ----
    coi <- read_asv("COI")
    dna <- DNAStringSet(coi$sequence)
    code5 <- getGeneticCode("5")
    stops_in_frame <- sapply(1:3, function(f)
      vcountPattern("*", suppressWarnings(translate(subseq(dna, f), genetic.code = code5,
                                                    if.fuzzy.codon = "X"))))
    # The coding frame is the one that is stop-free for the most reads
    frame <- which.max(colSums((stops_in_frame == 0) * (coi$W1 + coi$W2)))
    coi[, `:=`(stops = stops_in_frame[, frame],
               codon_ok = abs(length - 313) %% 3 == 0 & abs(length - 313) <= 30)]
    coi[, keep := codon_ok & stops == 0]
    coi[, reason := fifelse(keep, "kept", fifelse(!codon_ok, "length not codon-compatible", "stop codon in frame"))]
    
    # ---- 18S ----
    s18 <- read_asv("18S")
    s18[, keep := length >= 350 & length <= 480]
    s18[, reason := fifelse(keep, "kept", "outside 350–480 bp")]
    
    # ---- Write curated tables + summary ----
    summ <- rbindlist(lapply(list(COI = coi, `18S` = s18), function(d)
      d[, .(asvs = .N, W1_reads = sum(W1), W2_reads = sum(W2)), by = reason]), idcol = "marker")
    fwrite(summ, file.path(out, "curation_summary.tsv"), sep = "\t")
    fwrite(coi[, .(asv, length, stops, reason)], file.path(out, "COI_frame_check.tsv"), sep = "\t")
    cat(sprintf("COI coding frame: +%d\n", frame), file = file.path(out, "COI_frame.txt"))
    
    for (m in c("COI", "18S")) {
      d <- if (m == "COI") coi else s18
      k <- d[keep == TRUE]
      fwrite(k[, .(asv, W1, W2, length, sequence)], file.path(out, sprintf("%s_asv_table_curated.tsv", m)), sep = "\t")
      writeLines(paste0(">", k$asv, "\n", k$sequence), file.path(out, sprintf("%s_asv_seqs_curated.fasta", m)))
    }
    
    # ---- Plot: ASV length vs reads, coloured by curation outcome ----
    both <- rbind(coi[, .(marker = "COI", length, reads = W1 + W2, reason)],
                  s18[, .(marker = "18S", length, reads = W1 + W2, reason)])
    p <- ggplot(both, aes(length, reads, colour = reason)) +
      geom_point(alpha = 0.7, size = 1.8) + facet_wrap(~marker, scales = "free_x") +
      scale_y_log10(labels = scales::comma) +
      scale_colour_manual(values = c(kept = "#2b6c8f", `stop codon in frame` = "#d95f02",
                                     `length not codon-compatible` = "#e7a33e",
                                     `outside 350–480 bp` = "#e7a33e")) +
      labs(x = "ASV length (bp)", y = "Reads (log scale)", colour = NULL,
           title = "ASV curation — each point is one ASV") +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom")
    ggsave("docs/img/04_curation.png", p, width = 9, height = 4.8, dpi = 120)
    print(summ)
    

    View on GitHub

    markerreasonasvsChandubi readsDeepor reads
    COIkept345116,954102,761
    COIlength not codon-compatible29690207
    COIstop codon in frame291
    18Skept98288,56481,828
    18Soutside 350–480 bp534,44870

    results/04_curation/curation_summary.tsv

    Output files

  6. Step 5

    What we ended up with

    In plain wordsA final check: how much of the raw data made it through, how many signatures each lake has, and — the key question — did we sequence deeply enough to find nearly everything?

    63%of raw data used
    98218S signatures
    345COI signatures
    <b>Did we look hard enough?</b> Like a birdwatcher counting new species: at first every few minutes brings a new one, later almost none. Each line climbs quickly then flattens — sequencing more would have found very few new signatures. Depth was sufficient in both lakes for both barcodes.
    Did we look hard enough? Like a birdwatcher counting new species: at first every few minutes brings a new one, later almost none. Each line climbs quickly then flattens — sequencing more would have found very few new signatures. Depth was sufficient in both lakes for both barcodes.
    Signatures found only in Chandubi (blue), only in Deepor (orange), and the ones in both lakes (grey).
    Signatures found only in Chandubi (blue), only in Deepor (orange), and the ones in both lakes (grey).
    How many DNA fragments remained after each step.
    How many DNA fragments remained after each step.
    For specialists — scripts, parameters & full tables
    Script scripts/05_summary.R
    # Step 5 — Processing summary: reads surviving each step, ASVs per site and shared
    # between sites, and rarefaction curves (was sequencing depth sufficient?).
    dir.create("work/plots", recursive = TRUE, showWarnings = FALSE)
    suppressPackageStartupMessages({ library(data.table); library(ggplot2); library(vegan) })
    
    out <- "results/05_summary"
    dir.create(out, recursive = TRUE, showWarnings = FALSE)
    raw <- fread("results/01_qc/read_stats.tsv")[read == "R1", .(marker, sample, raw = reads)]
    site_of <- c(W1 = "Chandubi (W1)", W2 = "Deepor Beel (W2)")
    
    rows <- list()
    for (m in c("18S", "COI")) {
      tr <- fread(file.path("results/03_dada2", m, "track.tsv"))
      cur <- fread(file.path("results/04_curation", sprintf("%s_asv_table_curated.tsv", m)))
      tr[, curated := c(W1 = sum(cur$W1), W2 = sum(cur$W2))[sample]]
      rows[[m]] <- merge(raw[marker == m], tr, by = c("marker", "sample"))
    }
    track <- rbindlist(rows)[, .(marker, sample, raw, primers_found = input, filtered,
                                 merged, nonchim, curated)]
    track[, pct_retained := round(100 * curated / raw, 1)]
    fwrite(track, file.path(out, "read_tracking.tsv"), sep = "\t")
    
    tl <- melt(track, id.vars = c("marker", "sample"),
               measure.vars = c("raw", "primers_found", "filtered", "merged", "nonchim", "curated"),
               variable.name = "step", value.name = "reads")
    tl[, site := site_of[sample]]
    tl[, step := factor(step, levels = c("raw", "primers_found", "filtered", "merged", "nonchim", "curated"),
                        labels = c("Raw", "Primers found", "Filtered", "Merged", "Non-chimeric", "Curated"))]
    p1 <- ggplot(tl, aes(step, reads, group = site, colour = site)) +
      geom_line(linewidth = 1) + geom_point(size = 2) + facet_wrap(~marker) +
      scale_y_continuous(labels = scales::comma, limits = c(0, NA)) +
      scale_colour_manual(values = c("#2b6c8f", "#c2662d")) +
      labs(x = NULL, y = "Read pairs", colour = NULL, title = "Reads surviving each processing step") +
      theme_minimal(base_size = 12) +
      theme(legend.position = "bottom", axis.text.x = element_text(angle = 30, hjust = 1))
    saveRDS(p1, "work/plots/05_read_tracking.rds")
    ggsave("docs/img/05_read_tracking.png", p1, width = 9, height = 4.8, dpi = 120)
    
    asv_stats <- list(); curves <- list()
    for (m in c("18S", "COI")) {
      cur <- fread(file.path("results/04_curation", sprintf("%s_asv_table_curated.tsv", m)))
      asv_stats[[m]] <- data.table(marker = m,
        W1_asvs = sum(cur$W1 > 0), W2_asvs = sum(cur$W2 > 0),
        shared_asvs = sum(cur$W1 > 0 & cur$W2 > 0), total_asvs = nrow(cur),
        shared_reads_pct_W1 = round(100 * sum(cur$W1[cur$W2 > 0]) / sum(cur$W1), 1),
        shared_reads_pct_W2 = round(100 * sum(cur$W2[cur$W1 > 0]) / sum(cur$W2), 1))
      mat <- t(as.matrix(cur[, .(W1, W2)]))
      asv_stats[[m]][, `:=`(
        bray_curtis = round(as.numeric(vegdist(decostand(mat, "total"), "bray")), 3),
        jaccard = round(as.numeric(vegdist(mat, "jaccard", binary = TRUE)), 3))]
      for (s in rownames(mat)) {
        depth <- unique(round(seq(1, sum(mat[s, ]), length.out = 60)))
        curves[[paste(m, s)]] <- data.table(marker = m, site = site_of[s], depth = depth,
                                            asvs = as.numeric(rarefy(mat[s, ], depth)))
      }
    }
    asv_stats <- rbindlist(asv_stats)
    fwrite(asv_stats, file.path(out, "asv_counts.tsv"), sep = "\t")
    
    p2 <- ggplot(rbindlist(curves), aes(depth, asvs, colour = site)) +
      geom_line(linewidth = 1) + facet_wrap(~marker, scales = "free") +
      scale_x_continuous(labels = scales::comma) +
      scale_colour_manual(values = c("#2b6c8f", "#c2662d")) +
      labs(x = "Reads sampled", y = "ASVs observed", colour = NULL,
           title = "Rarefaction — curves flattening = depth was sufficient") +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom")
    saveRDS(p2, "work/plots/05_rarefaction.rds")
    ggsave("docs/img/05_rarefaction.png", p2, width = 9, height = 4.5, dpi = 120)
    
    al <- melt(asv_stats, id.vars = "marker",
               measure.vars = c("W1_asvs", "W2_asvs", "shared_asvs"), value.name = "asvs")
    al[, variable := factor(variable, labels = c("Chandubi (W1)", "Deepor Beel (W2)", "shared by both"))]
    p3 <- ggplot(al, aes(variable, asvs, fill = variable)) +
      geom_col(width = 0.6, show.legend = FALSE) + geom_text(aes(label = asvs), vjust = -0.4) +
      facet_wrap(~marker, scales = "free_y") +
      scale_fill_manual(values = c("#2b6c8f", "#c2662d", "#7a7a7a")) +
      labs(x = NULL, y = "ASVs", title = "ASVs per site after curation") +
      theme_minimal(base_size = 12)
    ggsave("docs/img/05_asv_counts.png", p3, width = 8, height = 4.2, dpi = 120)
    print(track); print(asv_stats)
    

    View on GitHub

    markersamplerawprimers foundfilteredmergednon-chimericcurated% of raw retained
    18SW1166,108145,813115,951104,11793,01288,56453.3
    18SW2143,035128,222101,02788,76881,89881,82857.2
    COIW1167,886148,980125,051123,126117,653116,95469.7
    COIW2143,351127,418107,049106,300102,969102,76171.7

    results/05_summary/read_tracking.tsv

    markerChandubiDeeporsharedtotal% Chandubi reads shared% Deepor reads sharedBray–CurtisJaccard
    18S43968514298236.524.60.9550.855
    COI33028126634599.499.90.2350.229

    results/05_summary/asv_counts.tsv

  7. Step 6

    Putting names to the signatures

    In plain wordsEach signature is still just a string of DNA letters. To name it, we compare it with large public libraries of DNA from organisms that experts have already identified — like matching a fingerprint against a database. A name is accepted only when the match is both confident and close enough: a species name, for example, needs the sequence to be at least 97–99% identical to a known one. If the match is weaker, we only claim a broader group (a family, or just “a rotifer”).

    87–93%18S reads named to major group
    11018S genera named
    8COI genera named

    What lives where (18S — the microscopic community)

    Chandubi Beel

    Rich in green algae (Chlorophyceae, ~14% of reads), diatoms (Cyclotella, Aulacoseira, Nitzschia), small amoeboid cercozoans, ciliates and rotifers.

    Deepor Beel

    Dominated by fungi (incl. chytrids that infect algae), desmids — ornate green algae such as Staurastrum and Cosmarium (~15%), water moulds (oomycetes), gastrotrichs and nematodes.

    Share of 18S reads in each major group. The two lakes look strikingly different: Chandubi is algae-and-protist rich, Deepor fungus- and desmid-rich.
    Share of 18S reads in each major group. The two lakes look strikingly different: Chandubi is algae-and-protist rich, Deepor fungus- and desmid-rich.

    Zooplankton (COI)

    Rotifers dominate the named animals in both lakes — Brachionus caudatus, B. calyciflorus, Keratella tropica and Polyarthra vulgaris among them. Copepods are present: Mesocyclops (both lakes), Microcyclops and Eucyclops appear in the 18S data and a trace of Mesocyclops in COI — the lab’s earlier report found none. Water fleas (cladocerans) were not detected.

    But most COI reads could not be named at all: only 31% (Chandubi) and 42% (Deepor) reach even phylum level. Their closest library matches are only 70–85% identical — too distant to name. Step 7 looks into them.

    How similar each sequence is to its closest relative in the library (bars weighted by reads). 18S piles up near 100% — well known organisms. COI is split: one peak near 98% (named rotifers) and a large mass between 70 and 85% — organisms with no close relative in the library.
    How similar each sequence is to its closest relative in the library (bars weighted by reads). 18S piles up near 100% — well known organisms. COI is split: one peak near 98% (named rotifers) and a large mass between 70 and 85% — organisms with no close relative in the library.
    Share of reads that could be named at each level, from broad (left) to precise (right).
    Share of reads that could be named at each level, from broad (left) to precise (right).
    For specialists — scripts, parameters & full tables
    18SCOI
    ReferencePR2 v5.1.1 (SSU, UTAX format)Eukaryote CO1 Reference Set v5.1.0 (Porter & Hajibabaei)
    ClassifierVSEARCH SINTAX, bootstrap cutoff 0.80, both strands
    Closest hitVSEARCH global alignment, top hit % identity
    Minimum identityfamily 95 · genus 97 · species 99 %order 85 · family 90 · genus 95 · species 97 %

    Ranks are accepted top-down; PR2 placeholder names (_X, _sp.) are left blank but do not block lower ranks. MIDORI2 was the intended COI reference, but its Zenodo host refused downloads from this network.

    Script scripts/06_taxonomy.sh
    #!/usr/bin/env bash
    # Step 6 — Taxonomic assignment of curated ASVs with VSEARCH.
    #  (a) SINTAX: k-mer classifier with bootstrap confidence per rank (cutoff applied in R).
    #  (b) Global alignment to the same reference: % identity of the closest reference sequence,
    #      used to decide whether genus/species names are close enough to trust.
    # References (downloaded to work/refs, git-ignored):
    #   18S : PR2 v5.1.1 SSU, UTAX/SINTAX format
    #         https://github.com/pr2database/pr2database/releases/tag/v5.1.1
    #   COI : Eukaryote CO1 Reference Set v5.1.0 (Porter & Hajibabaei), SINTAX format
    #         https://github.com/terrimporter/CO1Classifier/releases/tag/SINTAX-COI-v5.1.0-ref
    set -euo pipefail
    cd "$(dirname "$0")/.."
    
    REFS=work/refs
    OUT=results/06_taxonomy
    mkdir -p "$REFS" "$OUT"
    
    if [ ! -s "$REFS/pr2_5.1.1_UTAX.fasta.gz" ]; then
      curl -sL -o "$REFS/pr2_5.1.1_UTAX.fasta.gz" \
        https://github.com/pr2database/pr2database/releases/download/v5.1.1/pr2_version_5.1.1_SSU_UTAX.fasta.gz
    fi
    if [ ! -s "$REFS/COIv5.1.0_sintax.fasta" ]; then
      curl -sL -o "$REFS/coi.zip" \
        https://github.com/terrimporter/CO1Classifier/releases/download/SINTAX-COI-v5.1.0-ref/SINTAX_COIv5.1.0_ref.zip
      # sequences are distributed in lower case, which VSEARCH treats as masked -> upper-case them
      unzip -p "$REFS/coi.zip" training/sintax.fasta | awk '/^>/ {print; next} {print toupper($0)}' \
        > "$REFS/COIv5.1.0_sintax.fasta" && rm "$REFS/coi.zip"
    fi
    
    declare -A DB=( [18S]="$REFS/pr2_5.1.1_UTAX.fasta.gz" [COI]="$REFS/COIv5.1.0_sintax.fasta" )
    
    for m in 18S COI; do
      q=results/04_curation/${m}_asv_seqs_curated.fasta
      # (a) classifier, 100 bootstrap subsamples, both strands
      vsearch --sintax "$q" --db "${DB[$m]}" --strand both --sintax_random --randseed 2026 \
        --tabbedout "$OUT/${m}_sintax.tsv" --threads 16 --quiet
      # (b) closest reference sequence (top hits only; ties kept)
      vsearch --usearch_global "$q" --db "${DB[$m]}" --id 0.70 --strand both \
        --maxaccepts 20 --maxrejects 500 --top_hits_only --output_no_hits \
        --userout "$OUT/${m}_tophit.tsv" --userfields query+target+id+alnlen \
        --threads 16 --quiet
    done
    
    Rscript scripts/06_taxonomy.R
    

    View on GitHub

    Script scripts/06_taxonomy.R
    # Step 6 — Turn raw SINTAX + top-hit output into a conservative taxonomy table.
    # A rank is accepted only if BOTH
    #   (1) SINTAX bootstrap confidence >= 0.80, and
    #   (2) the closest reference sequence is similar enough for that rank:
    #         COI : species >= 97 %, genus >= 95 %, family >= 90 %, order >= 85 %
    #         18S : species >= 99 %, genus >= 97 %, family >= 95 %
    # Ranks are accepted top-down; once one fails, all lower ranks are left unassigned.
    # PR2 placeholder names (e.g. "Dinophyceae_X", "Enoplea_XX_sp.") are left blank at their
    # own rank but do not stop lower ranks (e.g. order "Sphaeropleales_X" -> genus Desmodesmus).
    suppressPackageStartupMessages({ library(data.table); library(ggplot2) })
    
    out <- "results/06_taxonomy"
    CONF <- 0.80
    ranks <- list(
      COI = c(d = "Domain", k = "Kingdom", p = "Phylum", c = "Class", o = "Order",
              f = "Family", g = "Genus", s = "Species"),
      `18S` = c(k = "Domain", d = "Supergroup", p = "Division", c = "Class", o = "Order",
                f = "Family", g = "Genus", s = "Species"))
    min_id <- list(COI = c(Order = 85, Family = 90, Genus = 95, Species = 97),
                   `18S` = c(Family = 95, Genus = 97, Species = 99))
    site_of <- c(W1 = "Chandubi (W1)", W2 = "Deepor Beel (W2)")
    
    parse_sintax <- function(m) {
      s <- fread(file.path(out, sprintf("%s_sintax.tsv", m)), header = FALSE, sep = "\t", quote = "",
                 fill = TRUE, col.names = c("asv", "tax", "strand"))[, 1:2]
      r <- ranks[[m]]
      rbindlist(lapply(seq_len(nrow(s)), function(i) {
        parts <- if (is.na(s$tax[i]) || s$tax[i] == "") character() else strsplit(s$tax[i], ",")[[1]]
        d <- data.table(asv = s$asv[i], letter = sub(":.*", "", parts),
                        name = sub("^.:(.*)\\(.*$", "\\1", parts),
                        conf = as.numeric(sub(".*\\((.*)\\)$", "\\1", parts)))
        d[, rank := r[letter]][]
      }), fill = TRUE)
    }
    
    summarise_marker <- function(m) {
      r <- unname(ranks[[m]])
      counts <- fread(sprintf("results/04_curation/%s_asv_table_curated.tsv", m))[, .(asv, W1, W2, length)]
      long <- parse_sintax(m)
    
      hit <- fread(file.path(out, sprintf("%s_tophit.tsv", m)), header = FALSE, sep = "\t", quote = "",
                   col.names = c("asv", "target", "identity", "alnlen"))
      hit <- hit[target != "*"][order(asv, -identity)][, .SD[1], by = asv]
      hit[, best_match := gsub("_", " ", sub(".*s:", "", target))]
    
      # Accept ranks top-down
      long <- merge(long, hit[, .(asv, identity)], by = "asv", all.x = TRUE)
      long[, need := min_id[[m]][rank]][is.na(need), need := 0]
      # PR2 joins division and subdivision ("Chlorophyta-Chlorophyta_X"): keep the division part
      long[, name := sub("-[^-]*_X+$", "", name)]
      long[, placeholder := grepl("(_X+|_sp\\.)$", name) | grepl("^undef_", name)]
      long[, ok := conf >= CONF & !is.na(identity) & identity >= need]
      long[, rank := factor(rank, levels = r)]
      setorder(long, asv, rank)
      long[, ok := cumprod(ok) == 1, by = asv]
    
      # Placeholders are left blank at their own rank but do not block the ranks below
      wide <- dcast(long[ok == TRUE & !placeholder], asv ~ rank, value.var = "name")
      for (x in r) if (!x %in% names(wide)) wide[, (x) := NA_character_]
      conf_wide <- dcast(long, asv ~ rank, value.var = "conf")
      setnames(conf_wide, setdiff(names(conf_wide), "asv"), paste0("conf_", setdiff(names(conf_wide), "asv")))
    
      tax <- Reduce(function(a, b) merge(a, b, by = "asv", all.x = TRUE),
                    list(counts, wide[, c("asv", r), with = FALSE], conf_wide,
                         hit[, .(asv, identity, best_match)]))
      tax[, deepest := apply(.SD, 1, function(v) { k <- which(!is.na(v)); if (length(k)) r[max(k)] else "Unassigned" }),
          .SDcols = r]
      tax[, label := apply(.SD, 1, function(v) { k <- which(!is.na(v)); if (length(k)) v[max(k)] else "Unassigned" }),
          .SDcols = r]
      tax[, label := gsub("_", " ", label)]
      setorder(tax, -W1)
      fwrite(tax[order(asv)], file.path(out, sprintf("%s_taxonomy.tsv", m)), sep = "\t")
      tax[, marker := m][]
    }
    
    tax <- list(COI = summarise_marker("COI"), `18S` = summarise_marker("18S"))
    
    # ---- How deep could reads be named? ----
    depth <- rbindlist(lapply(names(tax), function(m) {
      d <- tax[[m]]; r <- unname(ranks[[m]])
      rbindlist(lapply(r[-1], function(x) data.table(
        marker = m, rank = x,
        W1 = round(100 * sum(d$W1[!is.na(d[[x]])]) / sum(d$W1), 1),
        W2 = round(100 * sum(d$W2[!is.na(d[[x]])]) / sum(d$W2), 1))))
    }))
    fwrite(depth, file.path(out, "assignment_depth.tsv"), sep = "\t")
    dl <- melt(depth, id.vars = c("marker", "rank"), variable.name = "sample", value.name = "pct")
    dl[, site := site_of[sample]]
    dl[, rank := factor(rank, levels = c("Supergroup", "Kingdom", "Division", "Phylum", "Class",
                                         "Order", "Family", "Genus", "Species"))]
    p1 <- ggplot(dl, aes(rank, pct, colour = site, group = site)) +
      geom_line(linewidth = 1) + geom_point(size = 2.2) + facet_wrap(~marker, scales = "free_x") +
      scale_y_continuous(limits = c(0, 100), labels = function(x) paste0(x, "%")) +
      scale_colour_manual(values = c("#2b6c8f", "#c2662d")) +
      labs(x = NULL, y = "Reads named at this rank", colour = NULL,
           title = "How far down the tree of life could reads be named?") +
      theme_minimal(base_size = 12) +
      theme(legend.position = "bottom", axis.text.x = element_text(angle = 30, hjust = 1))
    ggsave("docs/img/06_assignment_depth.png", p1, width = 9, height = 4.8, dpi = 120)
    
    # ---- Composition per lake ----
    comp_plot <- function(m, rank, top, file, title) {
      d <- copy(tax[[m]])
      d[, grp := fifelse(is.na(get(rank)), "Unassigned", gsub("_", " ", get(rank)))]
      cmp <- d[, .(W1 = sum(W1), W2 = sum(W2)), by = grp]
      cmp[, `:=`(W1 = 100 * W1 / sum(W1), W2 = 100 * W2 / sum(W2))]
      keep <- cmp[grp != "Unassigned"][order(-(W1 + W2))][seq_len(min(top, .N)), grp]
      cmp[!grp %in% c(keep, "Unassigned"), grp := "Other named"]
      cmp <- cmp[, .(W1 = sum(W1), W2 = sum(W2)), by = grp]
      fwrite(cmp[order(-(W1 + W2))][, `:=`(W1 = round(W1, 2), W2 = round(W2, 2))],
             file.path(out, sprintf("%s_composition_%s.tsv", m, tolower(rank))), sep = "\t")
      cl <- melt(cmp, id.vars = "grp", variable.name = "sample", value.name = "pct")
      cl[, site := site_of[sample]]
      lv <- c(keep, "Other named", "Unassigned")
      cl[, grp := factor(grp, levels = rev(lv[lv %in% grp]))]
      pal <- c("#2b6c8f", "#c2662d", "#5a9e6f", "#8a6bb0", "#d4a72c", "#4fa3b8", "#b5536b",
               "#7d8c3a", "#a0785a", "#5d6fb3", "#c98bb9", "#3f7f6e")
      cols <- setNames(c(pal[seq_along(keep)], "#c9c9c9", "#ededed"), c(keep, "Other named", "Unassigned"))
      p <- ggplot(cl, aes(site, pct, fill = grp)) +
        geom_col(width = 0.55, colour = "white", linewidth = 0.3) +
        scale_fill_manual(values = cols, breaks = lv) +
        scale_y_continuous(labels = function(x) paste0(x, "%"), expand = c(0, 0)) +
        labs(x = NULL, y = "Share of reads", fill = rank, title = title) +
        theme_minimal(base_size = 12) + guides(fill = guide_legend(ncol = 1))
      ggsave(file, p, width = 7.5, height = 5.2, dpi = 120)
    }
    comp_plot("COI", "Phylum", 8, "docs/img/06_COI_phylum.png", "COI — animal groups (phylum)")
    comp_plot("COI", "Class", 10, "docs/img/06_COI_class.png", "COI — class")
    comp_plot("18S", "Division", 11, "docs/img/06_18S_division.png", "18S — major groups (division)")
    comp_plot("18S", "Class", 12, "docs/img/06_18S_class.png", "18S — class")
    
    # ---- Named genera and species ----
    named <- rbindlist(lapply(names(tax), function(m) {
      d <- tax[[m]][!is.na(Genus)]
      grp_col <- if (m == "COI") "Phylum" else "Division"
      d[, .(marker = m, group = get(grp_col)[1], Genus,
            Species = gsub("_", " ", Species),
            asvs = .N, W1_reads = sum(W1), W2_reads = sum(W2),
            W1_pct = round(100 * sum(W1) / sum(tax[[m]]$W1), 2),
            W2_pct = round(100 * sum(W2) / sum(tax[[m]]$W2), 2),
            best_identity = max(identity)), by = .(Genus, Species)][, -(1:2)]
    }))
    setorder(named, marker, -W1_pct)
    fwrite(named, file.path(out, "named_genera_species.tsv"), sep = "\t")
    
    # ---- Identity of best match, weighted by reads ----
    idd <- rbindlist(lapply(names(tax), function(m) tax[[m]][, .(marker = m, identity, reads = W1 + W2)]))
    idd[is.na(identity), identity := 69]
    p3 <- ggplot(idd, aes(identity, weight = reads)) +
      geom_histogram(binwidth = 1, fill = "#2b6c8f") + facet_wrap(~marker, scales = "free_y") +
      geom_vline(data = data.table(marker = c("COI", "18S"), x = c(97, 99)), aes(xintercept = x),
                 linetype = 2, colour = "#c2662d") +
      scale_y_continuous(labels = scales::comma) +
      labs(x = "Similarity to closest reference sequence (%)", y = "Reads",
           title = "How close is each sequence to something already in the library?",
           subtitle = "Dashed line: similarity needed to accept a species name") +
      theme_minimal(base_size = 12)
    ggsave("docs/img/06_identity.png", p3, width = 9, height = 4.5, dpi = 120)
    
    # ---- Summary ----
    summ <- rbindlist(lapply(names(tax), function(m) {
      d <- tax[[m]]
      data.table(marker = m, asvs = nrow(d),
                 asvs_to_genus = sum(!is.na(d$Genus)), asvs_to_species = sum(!is.na(d$Species)),
                 named_genera = uniqueN(na.omit(d$Genus)), named_species = uniqueN(na.omit(d$Species)),
                 reads_below_85pct_identity = round(100 * sum((d$W1 + d$W2)[is.na(d$identity) | d$identity < 85]) /
                                                      sum(d$W1 + d$W2), 1))
    }))
    fwrite(summ, file.path(out, "taxonomy_summary.tsv"), sep = "\t")
    print(summ); print(depth)
    print(named[, .(marker, group, Genus, Species, W1_pct, W2_pct, best_identity)][order(marker, -(W1_pct + W2_pct))][, head(.SD, 15), by = marker])
    

    View on GitHub

    markerasvsasvs_to_genusasvs_to_speciesnamed_generanamed_speciesreads_below_85pct_identity
    COI345494181063.3
    18S98220054110399.9

    results/06_taxonomy/taxonomy_summary.tsv

    markerrankW1W2
    COIKingdom78.377
    COIPhylum3142.4
    COIClass30.342
    COIOrder27.539.2
    COIFamily2032.1
    COIGenus19.932.1
    COISpecies9.412.9
    18SSupergroup90.191.2
    18SDivision86.993
    18SClass84.988.2
    18SOrder56.446.8
    18SFamily39.338.3
    18SGenus3931.4
    18SSpecies61.9

    results/06_taxonomy/assignment_depth.tsv

    18S, class level.
    18S, class level.
    COI, phylum level.
    COI, phylum level.
    COI, class level.
    COI, class level.

    Output files

  8. Step 7

    Looking up the unknowns in GenBank

    In plain wordsThe COI library used in step 6 is curated and so leaves things out. GenBank, run by the US National Center for Biotechnology Information (NCBI), holds nearly every DNA sequence ever published. We searched it (with a tool called BLAST) for the 40 most abundant COI signatures that step 6 could not name — together about 64% of Chandubi’s and 57% of Deepor’s COI reads.

    Most of them are rotifers that simply aren’t in reference libraries yet. Several match unnamed “rotifer environmental samples” almost exactly (98–100%), sequences found by other studies but never tied to a named species; others are 80–89% similar to known rotifers such as Brachionus, Trichocerca and Anuraeopsis — likely related species that have never been barcoded. Counting these, rotifers make up roughly 60% of Chandubi’s and 65% of Deepor’s COI reads.

    The single most common COI signature is a true unknown. COI_ASV0001 accounts for 15% (Chandubi) and 19% (Deepor) of COI reads, reads cleanly as a protein-coding gene, yet nothing in all of GenBank is more than about 72% identical to it. It belongs to an organism whose DNA has never been sequenced — possibly a new or unstudied local species.

    60% / 65%COI reads that are rotifers (Chandubi / Deepor)
    ~72%best match for the top unknown
    40sequences searched
    The 25 most abundant ‘unknowns’ and their closest match in GenBank, with % identity. Above ~97%: same species. 80–90%: a relative in the same group. Below 80%: no meaningful relative on record.
    The 25 most abundant ‘unknowns’ and their closest match in GenBank, with % identity. Above ~97%: same species. 80–90%: a relative in the same group. Below 80%: no meaningful relative on record.
    For specialists — scripts, parameters & full tables

    NCBI BLAST URL API, database core_nt, megablast (E ≤ 1e-20, 5 hits per query); queries with no megablast hit re-run with blastn (word size 11). Best hit = highest identity × query cover. Lineage from NCBI Taxonomy (E-utilities). Hits below 80% identity are not assigned to any group.

    Script scripts/06b_blast_unknowns.sh
    #!/usr/bin/env bash
    # Step 6b — The COI reference library could not place many abundant ASVs (< 85 % similar
    # to anything in it). Send the most abundant of these to NCBI BLAST (core_nt, remote) to
    # see what they are (NCBI BLAST URL API, megablast). Top 40 by total reads.
    set -euo pipefail
    cd "$(dirname "$0")/.."
    
    OUT=results/06_taxonomy
    N=40
    
    # ASVs whose closest library match is < 85 % (or none), most abundant first
    awk -F'\t' 'NR==1 {for (i=1;i<=NF;i++) h[$i]=i; next}
                ($h["identity"]=="" || $h["identity"]<85) {print $h["asv"] "\t" $h["W1"]+$h["W2"]}' \
      "$OUT/COI_taxonomy.tsv" | sort -t$'\t' -k2,2nr | head -n "$N" | cut -f1 > work/blast_ids.txt
    awk 'NR==FNR {want[">" $1]=1; next} /^>/ {keep = ($1 in want)} keep' \
      work/blast_ids.txt results/04_curation/COI_asv_seqs_curated.fasta > work/blast_query.fasta
    
    python3 scripts/06b_ncbi_blast.py work/blast_query.fasta "$OUT/COI_blast_unknowns.tsv" 5
    
    # Queries with no megablast hit: retry with sensitive blastn and append
    cut -f1 "$OUT/COI_blast_unknowns.tsv" | sort -u > work/blast_hit_ids.txt
    grep -vxFf work/blast_hit_ids.txt work/blast_ids.txt > work/blast_nohit_ids.txt || true
    if [ -s work/blast_nohit_ids.txt ]; then
      awk 'NR==FNR {want[">" $1]=1; next} /^>/ {keep = ($1 in want)} keep' \
        work/blast_nohit_ids.txt results/04_curation/COI_asv_seqs_curated.fasta > work/blast_query2.fasta
      python3 scripts/06b_ncbi_blast.py work/blast_query2.fasta work/blast_sensitive.tsv 5 --sensitive
      cat work/blast_sensitive.tsv >> "$OUT/COI_blast_unknowns.tsv"
    fi
    
    Rscript scripts/06b_blast_summary.R
    

    View on GitHub

    Script scripts/06b_ncbi_blast.py
    #!/usr/bin/env python3
    """Step 6b helper — BLAST a FASTA file against NCBI core_nt through the NCBI BLAST
    URL API (submit, poll, fetch). Writes one tab-separated row per hit:
    qseqid, accession, pident, align_len, qcov, evalue, taxid, sciname, kingdom, phylum, class.
    Usage: 06b_ncbi_blast.py query.fasta out.tsv [max_hits] [--sensitive]
    --sensitive uses blastn (word size 11) instead of megablast, for distant matches.
    Appends kingdom, phylum and class of each hit from NCBI Taxonomy."""
    import json
    import re
    import sys
    import time
    import urllib.parse
    import urllib.request
    
    URL = "https://blast.ncbi.nlm.nih.gov/Blast.cgi"
    query, out = sys.argv[1], sys.argv[2]
    max_hits = int(sys.argv[3]) if len(sys.argv) > 3 else 5
    sensitive = "--sensitive" in sys.argv
    
    
    def call(params, data=False):
        enc = urllib.parse.urlencode(params).encode()
        req = urllib.request.Request(URL, data=enc if data else None,
                                     headers={"User-Agent": "Sahil_Metagenome/1.0 (amplicon taxonomy)"})
        if not data:
            req.full_url = URL + "?" + enc.decode()
        with urllib.request.urlopen(req, timeout=120) as r:
            return r.read()
    
    
    fasta = open(query).read()
    qlen = {m.group(1): len(m.group(2).replace("\n", ""))
            for m in re.finditer(r">(\S+)[^\n]*\n([^>]*)", fasta)}
    
    if sensitive:
        mode = {"PROGRAM": "blastn", "DATABASE": "core_nt", "MEGABLAST": "off", "WORD_SIZE": 11}
    else:
        mode = {"PROGRAM": "blastn", "DATABASE": "core_nt", "MEGABLAST": "on"}
    resp = call({"CMD": "Put", **mode,
                 "QUERY": fasta, "HITLIST_SIZE": max_hits, "EXPECT": "1e-20",
                 "EMAIL": "", "TOOL": "Sahil_Metagenome"}, data=True).decode()
    rid = re.search(r"RID = (\S+)", resp).group(1)
    wait = int(re.search(r"RTOE = (\d+)", resp).group(1))
    print(f"RID {rid}, estimated {wait}s", file=sys.stderr)
    time.sleep(max(wait, 30))
    deadline = time.time() + 90 * 60  # give up after 90 minutes
    while True:
        if time.time() > deadline:
            sys.exit(f"BLAST RID {rid} did not finish within 90 minutes")
        status = call({"CMD": "Get", "RID": rid, "FORMAT_OBJECT": "SearchInfo"}).decode()
        s = re.search(r"Status=(\w+)", status).group(1)
        print(f"{time.strftime('%H:%M')} status {s}", file=sys.stderr, flush=True)
        if s == "READY":
            break
        if s in ("FAILED", "UNKNOWN"):
            sys.exit(f"BLAST {s} for RID {rid}")
        time.sleep(60)
    
    raw = call({"CMD": "Get", "RID": rid, "FORMAT_TYPE": "JSON2_S"})
    doc = json.loads(raw)
    rows = []
    for rep in doc["BlastOutput2"]:
        search = rep["report"]["results"]["search"]
        q = search["query_title"].split()[0]
        for hit in search.get("hits", []):
            d = hit["description"][0]
            hsp = hit["hsps"][0]
            rows.append([q, d.get("accession", ""), round(100 * hsp["identity"] / hsp["align_len"], 2),
                         hsp["align_len"], round(100 * (hsp["query_to"] - hsp["query_from"] + 1) / qlen[q], 1),
                         hsp["evalue"], str(d.get("taxid", "")), d.get("sciname", "")])
    
    # Higher taxonomy (phylum, class) for each hit, from NCBI Taxonomy via E-utilities
    lineage = {}
    taxids = sorted({r[6] for r in rows if r[6]})
    for i in range(0, len(taxids), 100):
        xml = urllib.request.urlopen(
            "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=taxonomy&id=" + ",".join(taxids[i:i + 100]),
            timeout=120).read().decode()
        for taxon in re.findall(r"<Taxon>\s*<TaxId>(\d+)</TaxId>(.*?)</LineageEx>", xml, re.S):
            tid, body = taxon
            ranks = dict((r, n) for n, r in re.findall(
                r"<ScientificName>([^<]+)</ScientificName>\s*<Rank>([^<]+)</Rank>", body))
            lineage[tid] = (ranks.get("kingdom", ranks.get("superkingdom", "")), ranks.get("phylum", ""),
                            ranks.get("class", ""))
        time.sleep(0.5)
    
    with open(out, "w") as fh:
        for r in rows:
            fh.write("\t".join(map(str, r + list(lineage.get(r[6], ("", "", ""))))) + "\n")
    print(f"wrote {out}", file=sys.stderr)
    

    View on GitHub

    Script scripts/06b_blast_summary.R
    # Step 6b — Summarise NCBI BLAST results for the most abundant COI ASVs that the
    # COI reference library could not place. Best hit = highest % identity × query cover.
    # Interpretation: >= 97 % species-level match, >= 90 % same genus/family likely,
    # 80-90 % only a distant relative is on record (phylum-level hint only);
    # < 80 % nothing informative is on record.
    suppressPackageStartupMessages({ library(data.table); library(ggplot2) })
    
    out <- "results/06_taxonomy"
    b <- fread(file.path(out, "COI_blast_unknowns.tsv"), header = FALSE, sep = "\t", quote = "",
               col.names = c("asv", "acc", "pident", "length", "qcovs", "evalue", "taxid", "name",
                             "kingdom", "phylum", "class"))
    b[, group := fifelse(phylum != "", phylum, fifelse(kingdom != "", kingdom, "unknown"))]
    tax <- fread(file.path(out, "COI_taxonomy.tsv"), sep = "\t", quote = "")
    ids <- readLines("work/blast_ids.txt")
    
    best <- b[order(asv, -(pident * qcovs))][, .SD[1], by = asv]
    res <- merge(data.table(asv = ids), best, by = "asv", all.x = TRUE)
    res <- merge(res, tax[, .(asv, W1, W2, library_identity = identity, library_label = label)], by = "asv")
    res[, match := fcase(is.na(pident), "no hit in NCBI",
                         pident >= 97 & qcovs >= 90, "species-level match",
                         pident >= 90 & qcovs >= 80, "close relative (genus/family)",
                         default = "distant relative only")]
    res[is.na(group), group := "no hit"]
    # Below 80 % identity the best hit is not informative even about the phylum
    res[!is.na(pident) & pident < 80, group := "no relative >= 80 %"]
    setorder(res, -W1)
    res[, reads_pct_W1 := round(100 * W1 / sum(tax$W1), 2)][, reads_pct_W2 := round(100 * W2 / sum(tax$W2), 2)]
    fwrite(res[, .(asv, W1, W2, reads_pct_W1, reads_pct_W2, library_identity, ncbi_best_hit = name,
                   ncbi_phylum = group, ncbi_class = class, ncbi_identity = pident, ncbi_query_cover = qcovs, accession = acc, match)],
           file.path(out, "COI_blast_unknowns_summary.tsv"), sep = "\t")
    
    # Reads in these ASVs, by NCBI group and match quality
    agg <- res[, .(W1 = sum(W1), W2 = sum(W2)), by = .(group, match)]
    fwrite(agg[order(-(W1 + W2))], file.path(out, "COI_blast_unknowns_by_group.tsv"), sep = "\t")
    
    res[, lab := sprintf("%s  ·  %s (%s%%)", asv,
                         fcase(is.na(name), "no hit", pident < 80, "no relative on record", default = name),
                         ifelse(is.na(pident), "–", format(round(pident, 1))))]
    res[, lab := factor(lab, levels = rev(lab))]
    pl <- melt(res[1:min(25, .N)], id.vars = c("lab", "match"), measure.vars = c("W1", "W2"),
               variable.name = "sample", value.name = "reads")
    pl[, site := c(W1 = "Chandubi (W1)", W2 = "Deepor Beel (W2)")[sample]]
    p <- ggplot(pl, aes(reads, lab, fill = site)) +
      geom_col(position = position_dodge(width = 0.75), width = 0.7) +
      scale_fill_manual(values = c("#2b6c8f", "#c2662d")) +
      scale_x_continuous(labels = scales::comma) +
      labs(x = "Reads", y = NULL, fill = NULL,
           title = "25 most abundant 'unknown' COI sequences: closest match in NCBI",
           subtitle = "Label: best NCBI hit (% identical). Below ~90 % only a distant relative is on record.") +
      theme_minimal(base_size = 11) +
      theme(legend.position = "bottom", plot.title.position = "plot")
    ggsave("docs/img/06b_blast_unknowns.png", p, width = 10, height = 8, dpi = 120)
    print(res[, .(asv, W1, W2, library_identity, name, group, pident, qcovs, match)])
    print(agg[order(-(W1 + W2))])
    

    View on GitHub

    groupmatchW1W2
    no relative >= 80 %distant relative only31,90328,624
    Rotiferadistant relative only18,22014,055
    Rotiferaspecies-level match19,25511,123
    Nemerteadistant relative only2,2052,149
    Metazoadistant relative only1,0821,006
    Pseudomonadotaclose relative (genus/family)90776
    unknowndistant relative only414305
    Oomycotadistant relative only396188
    Basidiomycotadistant relative only168209
    Arthropodadistant relative only108181
    Discoseadistant relative only55183

    results/06_taxonomy/COI_blast_unknowns_by_group.tsv

    Output files

  9. Step 8

    How diverse is each lake?

    In plain wordsDiversity has two sides: how many kinds there are, and how evenly they are spread. A lake with 100 kinds where one makes up 90% of everything is less diverse in practice than one with 100 equally common kinds. To compare fairly, both lakes are first cut down to the same number of reads — like comparing two birdwatchers who each watched for exactly one hour.

    We report diversity as an effective number of types: “this community is as diverse as one with N equally common types”. Counting every type gives the largest number; counting only the common ones gives a smaller one.

    436 vs 68518S types: Chandubi vs Deepor
    329 vs 281COI types: Chandubi vs Deepor
    14.5% / 77.1%types shared (18S / COI)

    The two barcodes tell opposite stories. For algae, protists and fungi (18S), Deepor is clearly richer — about 122 effective types against 74 in Chandubi. For zooplankton (COI), Chandubi is slightly richer and more even (43 vs 31), because Deepor is more dominated by a few rotifers.

    Diversity of each lake, at three levels of strictness. Numbers are the effective number of equally common types.
    Diversity of each lake, at three levels of strictness. Numbers are the effective number of equally common types.

    Looking at what the lakes share: by 18S, most types live in only one of the two lakes — even at genus level only 24.5% of named genera occur in both. By COI, most types are shared.

    Share of types found only in Chandubi, in both lakes, or only in Deepor — counted as exact sequences (ASV), named genera and named species. COI genus and species counts are small because few COI sequences could be named.
    Share of types found only in Chandubi, in both lakes, or only in Deepor — counted as exact sequences (ASV), named genera and named species. COI genus and species counts are small because few COI sequences could be named.
    For specialists — scripts, parameters & full tables

    Rarefied to the smaller lake’s depth per marker (18S 81,828; COI 102,761 reads), averaged over 100 random subsamples. Hill numbers: q0 = richness, q1 = exp(Shannon), q2 = inverse Simpson. Chao1 is not reported because DADA2 removes singletons. Jaccard and Bray–Curtis at genus/species level use only reads named at that level. One sample per lake: no significance tests are possible.

    Script scripts/08_diversity.R
    # Step 8 — Diversity.
    #  (a) Alpha diversity per lake, after rarefying both lakes to the same depth
    #      (mean of 100 random subsamples): observed ASVs, Shannon, Simpson, Pielou's evenness,
    #      and Hill numbers (q0 = richness, q1 = exp(Shannon), q2 = inverse Simpson) —
    #      the "effective number" of equally common types.
    #  (b) Overlap between lakes at ASV, genus and species level (shared / unique, Jaccard,
    #      Bray–Curtis). One sample per lake: descriptive only, no significance tests.
    # Chao1 is not reported: DADA2 discards singletons, which Chao1 depends on.
    dir.create("work/plots", recursive = TRUE, showWarnings = FALSE)
    suppressPackageStartupMessages({ library(data.table); library(ggplot2); library(vegan) })
    
    out <- "results/08_diversity"
    dir.create(out, recursive = TRUE, showWarnings = FALSE)
    site_of <- c(W1 = "Chandubi (W1)", W2 = "Deepor Beel (W2)")
    set.seed(2026)
    
    tax <- list(`18S` = fread("results/06_taxonomy/18S_taxonomy.tsv", sep = "\t", quote = "", na.strings = c("", "NA")),
                COI = fread("results/06_taxonomy/COI_taxonomy.tsv", sep = "\t", quote = "", na.strings = c("", "NA")))
    
    # ---- (a) alpha diversity ----
    alpha <- rbindlist(lapply(names(tax), function(m) {
      mat <- t(as.matrix(tax[[m]][, .(W1, W2)]))
      depth <- min(rowSums(mat))
      reps <- replicate(100, {
        r <- rrarefy(mat, depth)
        H <- diversity(r, "shannon"); S <- specnumber(r)
        cbind(observed = S, shannon = H, simpson = diversity(r, "simpson"),
              pielou = H / log(S), hill_q1 = exp(H), hill_q2 = diversity(r, "invsimpson"))
      }, simplify = "array")
      mean_mat <- apply(reps, c(1, 2), mean)
      data.table(marker = m, sample = rownames(mat), rarefied_to = depth, round(mean_mat, 3))
    }))
    fwrite(alpha, file.path(out, "alpha_diversity.tsv"), sep = "\t")
    
    hl <- melt(alpha, id.vars = c("marker", "sample"), measure.vars = c("observed", "hill_q1", "hill_q2"),
               variable.name = "q", value.name = "value")
    hl[, q := factor(q, levels = c("observed", "hill_q1", "hill_q2"),
                     labels = c("All types\n(richness)", "Typical types\n(exp Shannon)", "Dominant types\n(inv. Simpson)"))]
    hl[, site := site_of[sample]]
    p1 <- ggplot(hl, aes(q, value, fill = site)) +
      geom_col(position = position_dodge(width = 0.7), width = 0.65) +
      geom_text(aes(label = round(value)), position = position_dodge(width = 0.7), vjust = -0.4, size = 3.4) +
      facet_wrap(~marker, scales = "free_y") +
      scale_fill_manual(values = c("#2b6c8f", "#c2662d")) +
      scale_y_continuous(expand = expansion(mult = c(0, 0.12))) +
      labs(x = NULL, y = "Effective number of ASVs", fill = NULL,
           title = "How diverse is each lake?",
           subtitle = "Both lakes subsampled to equal depth. Left to right: counting every type → only the common ones.") +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom", plot.title.position = "plot")
    saveRDS(p1, "work/plots/08_alpha.rds")
    ggsave("docs/img/08_alpha.png", p1, width = 9.5, height = 5, dpi = 120)
    
    # ---- (b) overlap at several levels ----
    overlap <- rbindlist(lapply(names(tax), function(m) {
      d <- tax[[m]]
      rbindlist(lapply(c("ASV", "Genus", "Species"), function(level) {
        key <- if (level == "ASV") d$asv else d[[level]]
        agg <- d[!is.na(key), .(W1 = sum(W1), W2 = sum(W2)), by = .(k = key[!is.na(key)])]
        if (nrow(agg) == 0) return(NULL)
        mat <- t(as.matrix(agg[, .(W1, W2)]))
        data.table(marker = m, level = level,
                   chandubi_only = sum(agg$W1 > 0 & agg$W2 == 0), deepor_only = sum(agg$W1 == 0 & agg$W2 > 0),
                   shared = sum(agg$W1 > 0 & agg$W2 > 0), total = nrow(agg),
                   pct_shared = round(100 * sum(agg$W1 > 0 & agg$W2 > 0) / nrow(agg), 1),
                   jaccard_similarity = round(1 - as.numeric(vegdist(mat, "jaccard", binary = TRUE)), 3),
                   bray_curtis_dissimilarity = round(as.numeric(vegdist(decostand(mat, "total"), "bray")), 3))
      }))
    }))
    fwrite(overlap, file.path(out, "overlap_by_level.tsv"), sep = "\t")
    
    ol <- melt(overlap, id.vars = c("marker", "level"), measure.vars = c("chandubi_only", "shared", "deepor_only"),
               variable.name = "where", value.name = "n")
    ol[, where := factor(where, levels = c("chandubi_only", "shared", "deepor_only"),
                         labels = c("Chandubi only", "Both lakes", "Deepor only"))]
    ol[, pct := 100 * n / sum(n), by = .(marker, level)]
    ol[, level := factor(level, levels = c("ASV", "Genus", "Species"))]
    p2 <- ggplot(ol, aes(pct, level, fill = where)) +
      geom_col(width = 0.6, position = position_stack(reverse = TRUE)) +
      geom_text(aes(label = ifelse(n > 0, n, "")), position = position_stack(vjust = 0.5, reverse = TRUE),
                colour = "white", size = 3.4) +
      facet_wrap(~marker, ncol = 1) +
      scale_fill_manual(values = c("#2b6c8f", "#8c8c8c", "#c2662d")) +
      scale_x_continuous(labels = function(x) paste0(x, "%"), expand = expansion(mult = c(0, 0.02))) +
      labs(x = "Share of types (numbers = count)", y = NULL, fill = NULL,
           title = "What do the two lakes have in common?") +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom", plot.title.position = "plot")
    saveRDS(p2, "work/plots/08_overlap.rds")
    ggsave("docs/img/08_overlap.png", p2, width = 9, height = 5.5, dpi = 120)
    print(alpha); print(overlap)
    

    View on GitHub

    markersamplerarefied toobservedshannonsimpsonpielouHill q1Hill q2
    18SW181,828436.484.3030.960.70873.93825.157
    18SW281,8286854.8020.9760.735121.72442.208
    COIW1102,761329.153.7620.9490.64943.05319.771
    COIW2102,7612813.4180.930.60630.50214.346

    results/08_diversity/alpha_diversity.tsv

    markerlevelChandubi onlyDeepor onlysharedtotal% sharedJaccardBray–Curtis
    18SASV29754314298214.50.1450.955
    18SGenus24592711024.50.2450.875
    18SSpecies131973917.90.1790.972
    COIASV641526634577.10.7710.235
    COIGenus008810010.083
    COISpecies00101010010.172

    results/08_diversity/overlap_by_level.tsv

  10. Step 9

    Reading the lakes’ ecology

    In plain wordsA list of names becomes useful when we ask what those organisms do: who makes food from sunlight (algae), who eats (protozoa, animals), who recycles or infects (fungi, water moulds, parasites). Some organisms are also well-known indicators — their presence hints at how nutrient-rich or polluted water is.

    Who does what (18S)

    In Chandubi, about 29% of 18S reads come from algae — green algae and diatoms above all — and another 24% from free-living protozoa: a community built on producers and grazers. In Deepor, algae make up 27% (mostly desmids), while fungi (24%) and water moulds (9%) take a much larger share — a community leaning towards decomposition and parasitism, as expected where dead plant material is plentiful.

    Share of 18S reads by ecological role in each lake.
    Share of 18S reads by ecological role in each lake.

    Zooplankton groups (COI, including the GenBank look-ups)

    Rotifers make up about 60% (Chandubi) and 65% (Deepor) of COI reads. Crustaceans are scarce in this barcode — copepods, ostracods and insects together are under 1%. About a fifth of reads remain unknown, the largest part being the single unidentified organism found in Step 7.

    Share of COI reads by animal group.
    Share of COI reads by animal group.

    The main players

    The 30 most abundant named genera from both barcodes. Darker = more reads. Chandubi is marked by diatoms (<i>Cyclotella</i>, <i>Aulacoseira</i>) and green algae (<i>Desmodesmus</i>, <i>Chlamydomonas</i>); Deepor by water moulds (<i>Aphanomyces</i>), chytrid fungi (<i>Rhizophydium</i>), desmids (<i>Staurastrum</i>, <i>Cosmarium</i>) and golden algae. The rotifers <i>Brachionus</i> and <i>Keratella</i> are abundant in both.
    The 30 most abundant named genera from both barcodes. Darker = more reads. Chandubi is marked by diatoms (Cyclotella, Aulacoseira) and green algae (Desmodesmus, Chlamydomonas); Deepor by water moulds (Aphanomyces), chytrid fungi (Rhizophydium), desmids (Staurastrum, Cosmarium) and golden algae. The rotifers Brachionus and Keratella are abundant in both.

    Two barcodes see different animals

    Of the animal genera named, 14 were found only by 18S (flatworms, gastrotrichs, oligochaete worms, copepods Eucyclops and Microcyclops), 5 only by COI (the main rotifers Brachionus, Keratella, Polyarthra, Asplanchna, and the mayfly Cloeon), and just 1 by both (Mesocyclops). Using two barcodes roughly doubled the animal list — the main argument for a multi-marker design.

    Indicator organisms

    Several organisms that ecologists use as water-quality signals were found. The picture is mixed rather than simple:

    • Nutrient-rich signals in both lakes: the rotifers Brachionus and Keratella tropica are abundant everywhere and somewhat more so in Deepor. Chandubi additionally holds diatoms typical of enriched water (Cyclotella meneghiniana, Nitzschia palea, Aulacoseira granulata).
    • Deepor’s desmids (Staurastrum, Cosmarium) are a group often linked to plant-rich, less enriched water — fitting a macrophyte-covered wetland — alongside chytrid parasites of algae.

    These are pointers rather than verdicts; read together they are consistent with nutrient enrichment, strongest at Deepor beside the dumping ground.

    For specialists — scripts, parameters & full tables

    18S roles are assigned from PR2 division/class. COI groups use the reference-library phylum where available and the NCBI BLAST phylum (≥ 80% identity) for the 40 looked-up ASVs. Indicator notes in scripts/indicator_taxa.tsv are general associations from the water-quality literature, to be confirmed with regional sources before citing.

    Script scripts/09_ecology.R
    # Step 9 — Ecological reading of the named community.
    #  (a) 18S reads grouped by ecological role (algae by type, fungi, water moulds,
    #      protozoa, parasites, animals).
    #  (b) COI reads grouped by animal group, using the reference-library name where
    #      available and the NCBI BLAST phylum (>= 80 % identity) for the 40 looked-up unknowns.
    #  (c) Heatmap of the most abundant named genera from both markers.
    #  (d) Marker complementarity: which animal genera each barcode detected.
    #  (e) Indicator taxa: genera/species commonly used as water-quality indicators, with
    #      their share in each lake. Interpretation notes are general, not site-specific evidence.
    dir.create("work/plots", recursive = TRUE, showWarnings = FALSE)
    suppressPackageStartupMessages({ library(data.table); library(ggplot2) })
    
    out <- "results/09_ecology"
    dir.create(out, recursive = TRUE, showWarnings = FALSE)
    site_of <- c(W1 = "Chandubi (W1)", W2 = "Deepor Beel (W2)")
    t18 <- fread("results/06_taxonomy/18S_taxonomy.tsv", sep = "\t", quote = "", na.strings = c("", "NA"))
    tco <- fread("results/06_taxonomy/COI_taxonomy.tsv", sep = "\t", quote = "", na.strings = c("", "NA"))
    blast <- fread("results/06_taxonomy/COI_blast_unknowns_summary.tsv", sep = "\t", quote = "",
                   na.strings = c("", "NA"))
    pct <- function(x) 100 * x / sum(x)
    
    # ---- (a) 18S ecological roles ----
    diatoms <- c("Mediophyceae", "Coscinodiscophyceae", "Bacillariophyceae")
    other_algae <- c("Chrysophyceae", "Eustigmatophyceae", "Xanthophyceae")
    protozoa <- c("Rhizaria-Cercozoa", "Alveolata-Ciliophora", "Stramenopiles-Bigyra", "Tubulinea", "Evosea",
                  "Opisthokonta-Choanoflagellata", "Centroplasthelida", "Ancyromonadida", "Rigifilida",
                  "Alveolata-Chrompodellids")
    parasites <- c("Alveolata-Perkinsea", "Alveolata-Apicomplexa", "Opisthokonta-Ichthyosporea")
    t18[, role := fcase(
      Division == "Chlorophyta", "Green algae",
      Division == "Streptophyta" & Class == "Zygnemophyceae", "Desmids & conjugating algae",
      Division == "Streptophyta", "Plants",
      Class %in% diatoms, "Diatoms",
      Class %in% other_algae | Division %in% c("Glaucophyta", "Cryptophyta", "Haptophyta", "Rhodophyta"),
        "Golden & other algae",
      Division == "Alveolata-Dinoflagellata", "Dinoflagellates",
      Division == "Opisthokonta-Fungi", "Fungi",
      Class %in% c("Peronosporomycetes", "Hyphochytriomyceta"), "Water moulds (oomycetes)",
      Division %in% protozoa, "Protozoa (free-living)",
      Division %in% parasites, "Parasitic protists",
      Division == "Opisthokonta-Metazoa", "Animals",
      default = "Unassigned / other")]
    roles <- t18[, .(W1 = sum(W1), W2 = sum(W2)), by = role][, `:=`(W1 = round(pct(W1), 2), W2 = round(pct(W2), 2))]
    role_levels <- c("Green algae", "Desmids & conjugating algae", "Diatoms", "Golden & other algae", "Dinoflagellates",
                     "Plants", "Protozoa (free-living)", "Parasitic protists", "Fungi", "Water moulds (oomycetes)",
                     "Animals", "Unassigned / other")
    roles <- roles[order(match(role, role_levels))]
    fwrite(roles, file.path(out, "18S_ecological_roles.tsv"), sep = "\t")
    fwrite(t18[, .(asv, W1, W2, role, Division, Class, Genus, Species)], file.path(out, "18S_asv_roles.tsv"), sep = "\t")
    
    role_cols <- c("Green algae" = "#3f8f4f", "Desmids & conjugating algae" = "#8cc084", "Diatoms" = "#c9a227",
                   "Golden & other algae" = "#e3c770", "Dinoflagellates" = "#d08a4a", "Plants" = "#5b6b2f",
                   "Protozoa (free-living)" = "#4f8fb8", "Parasitic protists" = "#8a6bb0", "Fungi" = "#b5536b",
                   "Water moulds (oomycetes)" = "#d98aa0", "Animals" = "#2b4c6f", "Unassigned / other" = "#dcdcdc")
    rl <- melt(roles, id.vars = "role", variable.name = "sample", value.name = "pct")
    rl[, site := site_of[sample]][, role := factor(role, levels = rev(role_levels))]
    p1 <- ggplot(rl, aes(pct, site, fill = role)) +
      geom_col(width = 0.55, colour = "white", linewidth = 0.3) +
      scale_fill_manual(values = role_cols, breaks = role_levels) +
      scale_x_continuous(labels = function(x) paste0(x, "%"), expand = c(0, 0)) +
      labs(x = "Share of 18S reads", y = NULL, fill = NULL,
           title = "Who does what: the microscopic community by ecological role (18S)") +
      theme_minimal(base_size = 12) +
      theme(legend.position = "bottom", plot.title.position = "plot") + guides(fill = guide_legend(ncol = 3))
    saveRDS(p1, "work/plots/09_18S_roles.rds")
    ggsave("docs/img/09_18S_roles.png", p1, width = 10, height = 4.6, dpi = 120)
    
    # ---- (b) COI animal groups (library + BLAST) ----
    bl <- blast[ncbi_identity >= 80, .(asv, blast_phylum = ncbi_phylum, blast_class = ncbi_class)]
    tco <- merge(tco, bl, by = "asv", all.x = TRUE)
    tco[, phylum_final := fifelse(!is.na(Phylum), Phylum, blast_phylum)]
    tco[, class_final := fifelse(!is.na(Class), Class, blast_class)]
    tco[, group := fcase(
      phylum_final == "Rotifera", "Rotifers",
      phylum_final == "Arthropoda" & (grepl("Hexanauplia|Copepoda", class_final) | Genus %in% "Mesocyclops"), "Copepods",
      phylum_final == "Arthropoda" & class_final %in% "Ostracoda", "Ostracods",
      phylum_final == "Arthropoda", "Insects, mites & other arthropods",
      phylum_final %in% c("Nemertea", "Nematoda", "Mollusca", "Annelida", "Gastrotricha", "Platyhelminthes", "Metazoa"),
        "Other animals (worms etc.)",
      !is.na(phylum_final), "Non-animal (algae, fungi, bacteria…)",
      Kingdom %in% "Metazoa", "Animal, group unknown",
      default = "Unknown")]
    grp <- tco[, .(W1 = sum(W1), W2 = sum(W2)), by = group][, `:=`(W1 = round(pct(W1), 2), W2 = round(pct(W2), 2))]
    grp_levels <- c("Rotifers", "Copepods", "Ostracods", "Insects, mites & other arthropods", "Other animals (worms etc.)",
                    "Animal, group unknown", "Non-animal (algae, fungi, bacteria…)", "Unknown")
    grp <- grp[order(match(group, grp_levels))]
    fwrite(grp, file.path(out, "COI_animal_groups.tsv"), sep = "\t")
    fwrite(tco[, .(asv, W1, W2, group, phylum_final, class_final, Genus, Species, identity)],
           file.path(out, "COI_asv_groups.tsv"), sep = "\t")
    
    grp_cols <- c("Rotifers" = "#2b6c8f", "Copepods" = "#c2662d", "Ostracods" = "#e0a35a",
                  "Insects, mites & other arthropods" = "#8a6bb0", "Other animals (worms etc.)" = "#5a9e6f",
                  "Animal, group unknown" = "#9fb7c6", "Non-animal (algae, fungi, bacteria…)" = "#c9a227",
                  "Unknown" = "#dcdcdc")
    gl <- melt(grp, id.vars = "group", variable.name = "sample", value.name = "pct")
    gl[, site := site_of[sample]][, group := factor(group, levels = rev(grp_levels))]
    p2 <- ggplot(gl, aes(pct, site, fill = group)) +
      geom_col(width = 0.55, colour = "white", linewidth = 0.3) +
      scale_fill_manual(values = grp_cols, breaks = grp_levels) +
      scale_x_continuous(labels = function(x) paste0(x, "%"), expand = c(0, 0)) +
      labs(x = "Share of COI reads", y = NULL, fill = NULL,
           title = "Zooplankton by group (COI, reference library + GenBank look-up)") +
      theme_minimal(base_size = 12) +
      theme(legend.position = "bottom", plot.title.position = "plot") + guides(fill = guide_legend(ncol = 3))
    saveRDS(p2, "work/plots/09_COI_groups.rds")
    ggsave("docs/img/09_COI_groups.png", p2, width = 10, height = 4.4, dpi = 120)
    
    # ---- (c) heatmap of top genera, both markers ----
    gen <- rbind(t18[!is.na(Genus), .(marker = "18S", Genus, W1, W2, role)],
                 tco[!is.na(Genus), .(marker = "COI", Genus, W1, W2, role = group)])
    tot <- list(`18S` = c(W1 = sum(t18$W1), W2 = sum(t18$W2)), COI = c(W1 = sum(tco$W1), W2 = sum(tco$W2)))
    gen[, `:=`(W1p = 100 * W1 / tot[[marker]][["W1"]], W2p = 100 * W2 / tot[[marker]][["W2"]]), by = marker]
    g <- gen[, .(W1 = sum(W1p), W2 = sum(W2p), role = role[1]), by = .(marker, Genus)]
    topg <- g[order(-(pmax(W1, W2)))][1:30]
    fwrite(topg[, `:=`(W1 = round(W1, 3), W2 = round(W2, 3))], file.path(out, "top_genera.tsv"), sep = "\t")
    hm <- melt(topg, id.vars = c("marker", "Genus", "role"), measure.vars = c("W1", "W2"),
               variable.name = "sample", value.name = "pct")
    hm[, site := factor(site_of[sample], levels = site_of)]
    hm[, lab := sprintf("%s  (%s · %s)", gsub("_", " ", Genus), marker, role)]
    ord <- topg[order(W1 - W2), sprintf("%s  (%s · %s)", gsub("_", " ", Genus), marker, role)]
    hm[, lab := factor(lab, levels = ord)]
    p3 <- ggplot(hm, aes(site, lab, fill = pmax(pct, 0.001))) +
      geom_tile(colour = "white", linewidth = 0.6) +
      geom_text(aes(label = ifelse(pct >= 0.01, sprintf("%.2f", pct), "–"),
                    colour = ifelse(pct >= 0.5, "white", "black")), size = 3.1) +
      scale_colour_identity() +
      scale_fill_gradientn(colours = c("#f7f7f2", "#cfe3ec", "#7fb2cc", "#2b6c8f", "#163e57"), trans = "log10",
                           labels = function(x) paste0(x, "%"), name = "% of reads") +
      scale_x_discrete(position = "top") +
      labs(x = NULL, y = NULL, title = "The 30 most abundant named genera",
           subtitle = "% of all reads of that barcode in each lake. Top: Chandubi-leaning; bottom: Deepor-leaning") +
      theme_minimal(base_size = 11) + theme(panel.grid = element_blank(), plot.title.position = "plot")
    saveRDS(p3, "work/plots/09_genera_heatmap.rds")
    ggsave("docs/img/09_genera_heatmap.png", p3, width = 9, height = 9.5, dpi = 120)
    
    # ---- (d) marker complementarity for animals ----
    a18 <- t18[Division == "Opisthokonta-Metazoa" & !is.na(Genus), .(phylum = na.omit(Class)[1]), by = Genus]
    aco <- tco[Kingdom %in% "Metazoa" & !is.na(Genus), .(phylum = na.omit(Phylum)[1]), by = Genus]
    comp <- merge(a18[, .(Genus, phylum_18S = phylum, in_18S = TRUE)], aco[, .(Genus, phylum_COI = phylum, in_COI = TRUE)],
                  by = "Genus", all = TRUE)
    comp[is.na(in_18S), in_18S := FALSE][is.na(in_COI), in_COI := FALSE]
    comp[, group := fifelse(!is.na(phylum_18S), phylum_18S, phylum_COI)]
    comp[, detected_by := fcase(in_18S & in_COI, "both", in_18S, "18S only", default = "COI only")]
    fwrite(comp[order(detected_by, group, Genus), .(Genus, group, detected_by)], file.path(out, "animal_genera_by_marker.tsv"), sep = "\t")
    phyla <- rbind(t18[Division == "Opisthokonta-Metazoa" & !is.na(Class), .(marker = "18S", phylum = Class, W1, W2)],
                   tco[!is.na(phylum_final) & !group %in% c("Non-animal (algae, fungi, bacteria…)", "Unknown"),
                       .(marker = "COI", phylum = phylum_final, W1, W2)])
    fwrite(phyla[, .(asvs = .N, reads = sum(W1 + W2)), by = .(phylum, marker)][order(phylum, marker)],
           file.path(out, "animal_phyla_by_marker.tsv"), sep = "\t")
    
    # ---- (e) indicator taxa ----
    ind <- fread("scripts/indicator_taxa.tsv", sep = "\t", quote = "")
    lookup <- function(level, name, marker) {
      d <- if (marker == "18S") t18 else tco
      hit <- d[[level]] %in% name
      c(W1 = 100 * sum(d$W1[hit]) / sum(d$W1), W2 = 100 * sum(d$W2[hit]) / sum(d$W2))
    }
    ind_res <- rbindlist(lapply(seq_len(nrow(ind)), function(i) {
      v <- lookup(ind$level[i], ind$name[i], ind$marker[i])
      data.table(ind[i], chandubi_pct = round(v[["W1"]], 3), deepor_pct = round(v[["W2"]], 3))
    }))
    fwrite(ind_res, file.path(out, "indicator_taxa.tsv"), sep = "\t")
    
    print(roles); print(grp); print(comp[, .N, by = detected_by]); print(ind_res[, .(name, marker, chandubi_pct, deepor_pct)])
    

    View on GitHub

    roleW1W2
    Green algae16.134
    Desmids & conjugating algae0.1915.49
    Diatoms8.750.73
    Golden & other algae1.435.19
    Dinoflagellates2.551.76
    Plants00.2
    Protozoa (free-living)23.6910.08
    Parasitic protists5.710.64
    Fungi7.9424.1
    Water moulds (oomycetes)3.969.35
    Animals16.4921.39
    Unassigned / other13.167.06

    results/09_ecology/18S_ecological_roles.tsv

    groupW1W2
    Rotifers60.3964.63
    Copepods0.010
    Ostracods0.090.18
    Insects, mites & other arthropods0.490.11
    Other animals (worms etc.)1.892.09
    Animal, group unknown14.399.67
    Non-animal (algae, fungi, bacteria…)2.141.26
    Unknown20.5922.06

    results/09_ecology/COI_animal_groups.tsv

    namelevelmarkergroupgeneral associationChandubi %Deepor %
    BrachionusGenusCOIRotiferGenus widely used in the Brachionus:Trichocerca ratio; dominance usually read as nutrient-rich (eutrophic) water14.83422.747
    Brachionus_calyciflorusSpeciesCOIRotiferCommon in eutrophic, organically enriched waters1.1511.735
    Brachionus_caudatusSpeciesCOIRotiferWarm-water species typical of tropical nutrient-rich ponds and lakes4.5287.439
    Keratella_tropicaSpeciesCOIRotiferWarm-water species often abundant in eutrophic tropical lakes1.1112.358
    Polyarthra_vulgarisSpeciesCOIRotiferWidespread, tolerant of a broad range of conditions0.9070.539
    Filinia_longisetaSpecies18SRotiferFrequently associated with eutrophic, turbid waters1.3660
    MesocyclopsGenus18SCopepodPredatory cyclopoid copepod common in tropical lakes and ponds1.8460.572
    Cyclotella_meneghinianaSpecies18SDiatomTolerant of high nutrients and organic pollution; common in eutrophic waters1.9430.001
    Nitzschia_paleaSpecies18SDiatomClassic pollution-tolerant diatom used in diatom water-quality indices0.6330.034
    Aulacoseira_granulataSpecies18SDiatomTypical of eutrophic, well-mixed and turbid waters0.270
    StaurastrumGenus18SDesmidDesmid genus; desmid-rich communities are often linked to macrophyte-rich, less enriched waters0.0013.014
    CosmariumGenus18SDesmidDesmid genus with species spanning clean to moderately enriched waters01.229
    Gonyostomum_semenSpecies18SRaphidophyteNuisance flagellate of humic, brown-water lakes; can form blooms00.062
    Botryococcus_brauniiSpecies18SGreen algaColonial oil-rich green alga of lakes and ponds; can form surface blooms00.303
    RhizophydiumGenus18SChytrid fungusParasite of phytoplankton (including diatoms and green algae)03.414

    results/09_ecology/indicator_taxa.tsv

    Genusgroupdetected by
    AeolosomaAnnelida18S only
    EucyclopsArthropoda18S only
    MicrocyclopsArthropoda18S only
    ChaetonotusGastrotricha18S only
    LepidodermellaGastrotricha18S only
    DugesiaPlatyhelminthes18S only
    GieysztoriaPlatyhelminthes18S only
    MesostomaPlatyhelminthes18S only
    RhynchomesostomaPlatyhelminthes18S only
    StenostomumPlatyhelminthes18S only
    StrongylostomaPlatyhelminthes18S only
    FiliniaRotifera18S only
    HexarthraRotifera18S only
    MicrocodonRotifera18S only
    CloeonArthropodaCOI only
    AsplanchnaRotiferaCOI only
    BrachionusRotiferaCOI only
    KeratellaRotiferaCOI only
    PolyarthraRotiferaCOI only
    MesocyclopsArthropodaboth

    results/09_ecology/animal_genera_by_marker.tsv

    Output files

  11. Step 10

    A second look at the unknowns — through their proteins

    In plain wordsCOI is a recipe for a protein. Over evolution the DNA “spelling” changes much faster than the protein it makes — many different spellings give the same protein. So two organisms whose DNA looks only 75% alike can still have very similar proteins, which reveals their family ties. We translated the unknown sequences into protein and compared them with 2.2 million known COI proteins.

    22 of 32unknowns that are rotifers
    29% / 25%COI reads they add (Chandubi / Deepor)
    86%best protein match, top unknown

    Most unknowns are rotifers. Their proteins are 89–98% identical to known rotifers such as Trichocerca, Euchlanis, Filinia, Lecane and Brachionus — confirming that the lakes hold many rotifer species whose DNA has never been catalogued. A few others point to mites, an ostracod, a freshwater sponge and an amoeba.

    The most common COI signature remains a mystery. COI_ASV0001’s protein is closest to a nematode (roundworm, 86% identical) but almost as close to brown-algae-like stramenopiles (~81%). The evidence does not settle which group it belongs to — it may be an organism from a group that has barely been sequenced. Isolating and sequencing it (or its full mitochondrial genome) would be needed to name it.

    For each unknown: similarity to the closest known sequence at DNA level (orange) and protein level (blue). Protein similarity is much higher, which is what lets us place most of them. Label: group agreed by the closest proteins.
    For each unknown: similarity to the closest known sequence at DNA level (orange) and protein level (blue). Protein similarity is much higher, which is what lets us place most of them. Label: group agreed by the closest proteins.
    For specialists — scripts, parameters & full tables

    The Eukaryote CO1 Reference Set v5.1.0 was translated with SeqKit (genetic code 5; for each sequence the frame with fewest stop codons) and indexed with DIAMOND v2.2.8. Queries: the COI ASVs from step 7 without a ≥ 90% GenBank DNA match. DIAMOND blastx --ultra-sensitive, query genetic code 5, E ≤ 1e-10, 25 targets. Consensus phylum/class: ≥ 3 hits within 95% of the best bit score and ≥ 70% agreement. NCBI’s online BLASTx was tried first but its queue did not return results.

    Script scripts/10_protein_search.sh
    #!/usr/bin/env bash
    # Step 10 — Protein-level identification of COI ASVs that have no close DNA match in GenBank
    # (best BLASTn identity < 90 %). DNA changes faster than the protein it encodes, so comparing
    # translated sequences can still place a sequence in its phylum or class when DNA similarity
    # is too low to say anything.
    # Reference: the Eukaryote CO1 Reference Set (step 6) translated to protein — each sequence in
    # the reading frame with fewest stop codons, invertebrate mitochondrial code (table 5).
    # Search: DIAMOND blastx (local; NCBI's online BLASTx queue proved unusable for this run).
    set -euo pipefail
    cd "$(dirname "$0")/.."
    
    OUT=results/10_protein
    REFS=work/refs
    mkdir -p "$OUT"
    
    if [ ! -s "$REFS/COIv5.1.0_protein.dmnd" ]; then
      seqkit translate -j 8 -T 5 -f 1,2,3 -w 0 "$REFS/COIv5.1.0_sintax.fasta" 2>/dev/null |
        awk '/^>/ { h = $1; sub(/_frame=[0-9]+$/, "", h); next }
             { n = gsub(/\*/, "X"); if (!(h in best) || n < best[h]) { best[h] = n; seq[h] = $0 } }
             END { for (k in seq) print k "\n" seq[k] }' > "$REFS/COIv5.1.0_protein.faa"
      diamond makedb --in "$REFS/COIv5.1.0_protein.faa" -d "$REFS/COIv5.1.0_protein" --quiet
      rm "$REFS/COIv5.1.0_protein.faa"
    fi
    
    # Query: all COI ASVs that were looked up in GenBank and had no >= 90 % DNA match
    awk -F'\t' 'NR==1 {for (i=1;i<=NF;i++) h[$i]=i; next}
                ($h["ncbi_identity"]=="" || $h["ncbi_identity"]<90) {print $h["asv"]}' \
      results/06_taxonomy/COI_blast_unknowns_summary.tsv > work/protein_ids.txt
    awk 'NR==FNR {want[">" $1]=1; next} /^>/ {keep = ($1 in want)} keep' \
      work/protein_ids.txt results/04_curation/COI_asv_seqs_curated.fasta > work/protein_query.fasta
    
    diamond blastx -q work/protein_query.fasta -d "$REFS/COIv5.1.0_protein" --query-gencode 5 \
      --ultra-sensitive --max-target-seqs 25 --evalue 1e-10 --threads 16 --quiet \
      --outfmt 6 qseqid sseqid pident length qcovhsp evalue bitscore -o "$OUT/COI_protein_hits.tsv"
    
    Rscript scripts/10_protein_summary.R
    

    View on GitHub

    Script scripts/10_protein_summary.R
    # Step 10 — Summarise the protein-level (DIAMOND blastx) search for COI ASVs with no close DNA match.
    # Per ASV: best hit (highest bit score) and the consensus phylum / class shared by >= 70 % of the
    # top hits (all hits within 95 % of the best bit score, at least 3 of them); otherwise 'mixed'. Amino-acid identity is read loosely:
    # >= 95 % same or closely related genus; 85–95 % same family/order likely; < 85 % phylum/class hint.
    suppressPackageStartupMessages({ library(data.table); library(ggplot2) })
    
    out <- "results/10_protein"
    b <- fread(file.path(out, "COI_protein_hits.tsv"), header = FALSE, sep = "\t", quote = "",
               col.names = c("asv", "target", "aa_identity", "length", "qcov", "evalue", "bitscore"))
    rank_of <- function(x, l) {
      v <- regmatches(x, regexpr(paste0(l, ":[^,]+"), x))
      out <- rep(NA_character_, length(x)); out[grepl(paste0(l, ":"), x)] <- sub("^.:", "", v); out
    }
    b[, `:=`(phylum = rank_of(target, "p"), class = rank_of(target, "c"), genus = rank_of(target, "g"),
             species = gsub("_", " ", rank_of(target, "s")))]
    tax <- fread("results/06_taxonomy/COI_taxonomy.tsv", sep = "\t", quote = "", na.strings = c("", "NA"))
    dna <- fread("results/06_taxonomy/COI_blast_unknowns_summary.tsv", sep = "\t", quote = "", na.strings = c("", "NA"))
    ids <- readLines("work/protein_ids.txt")
    
    # Hits whose phylum is undefined in the reference (e.g. brown algae: "undef_undef_Eukaryota") are
    # labelled by class instead, so they still count against a consensus rather than being ignored.
    b[grepl("^undef_", phylum) | is.na(phylum), phylum := fifelse(is.na(class), "unknown", class)]
    consensus <- function(x) {
      x <- x[!is.na(x)]
      if (length(x) < 3) return(NA_character_)  # one or two close hits are not a consensus
      t <- sort(table(x), decreasing = TRUE)
      if (t[[1]] / length(x) >= 0.7) names(t)[1] else NA_character_
    }
    b <- b[order(asv, -bitscore)]
    top <- b[, .SD[bitscore >= 0.95 * max(bitscore)], by = asv]
    best <- b[, .SD[1], by = asv]
    cons <- top[, .(top_hits = .N, consensus_phylum = consensus(phylum), consensus_class = consensus(class)), by = asv]
    res <- Reduce(function(x, y) merge(x, y, by = "asv", all.x = TRUE),
                  list(data.table(asv = ids),
                       best[, .(asv, best_hit = species, aa_identity, qcov, evalue, best_phylum = phylum, best_class = class)],
                       cons, tax[, .(asv, W1, W2)],
                       dna[, .(asv, dna_identity = ncbi_identity, dna_best_hit = ncbi_best_hit)]))
    res[, reading := fcase(is.na(aa_identity), "no protein hit",
                           aa_identity >= 95, "close relative (genus level)",
                           aa_identity >= 85, "same family/order likely",
                           default = "phylum/class hint only")]
    res[, `:=`(reads_pct_W1 = round(100 * W1 / sum(tax$W1), 2), reads_pct_W2 = round(100 * W2 / sum(tax$W2), 2))]
    setorder(res, -W1)
    fwrite(res[, .(asv, reads_pct_W1, reads_pct_W2, dna_identity, dna_best_hit, best_hit, aa_identity, qcov, evalue,
                   best_phylum, best_class, consensus_phylum, consensus_class, top_hits, reading)],
           file.path(out, "COI_protein_summary.tsv"), sep = "\t")
    fwrite(res[, .(asvs = .N, W1_pct = sum(reads_pct_W1), W2_pct = sum(reads_pct_W2)),
               by = .(consensus_phylum, consensus_class)][order(-(W1_pct + W2_pct))],
           file.path(out, "COI_protein_by_group.tsv"), sep = "\t")
    
    pl <- copy(res[1:min(25, .N)])
    pl[, lab := sprintf("%s · %s / %s", asv, fifelse(is.na(consensus_phylum), "mixed", consensus_phylum),
                        fifelse(is.na(consensus_class), "–", consensus_class))]
    pl[, lab := factor(lab, levels = rev(lab))]
    pl <- melt(pl, id.vars = "lab", measure.vars = c("dna_identity", "aa_identity"),
               variable.name = "level", value.name = "identity")
    pl[, level := factor(level, levels = c("dna_identity", "aa_identity"), labels = c("DNA (BLASTn, GenBank)", "Protein (DIAMOND)"))]
    p <- ggplot(pl, aes(identity, lab, colour = level, shape = level)) +
      geom_line(aes(group = lab), colour = "#c9c9c9") + geom_point(size = 2.6, stroke = 1) +
      scale_colour_manual(values = c("#c2662d", "#2b6c8f")) +
      scale_shape_manual(values = c(1, 16)) +
      scale_x_continuous(labels = function(x) paste0(x, "%")) +
      labs(x = "Identity to closest known sequence", y = NULL, colour = NULL, shape = NULL,
           title = "Unknown COI sequences: DNA vs protein similarity",
           subtitle = "Label: consensus phylum / class of the closest proteins ('mixed' = no agreement)") +
      theme_minimal(base_size = 11) + theme(legend.position = "bottom", plot.title.position = "plot")
    dir.create("work/plots", recursive = TRUE, showWarnings = FALSE)
    saveRDS(p, "work/plots/10_protein.rds")
    ggsave("docs/img/10_protein.png", p, width = 9.5, height = 7, dpi = 120)
    print(res[, .(asv, reads_pct_W1, reads_pct_W2, dna_identity, best_hit, aa_identity, consensus_phylum, consensus_class)])
    

    View on GitHub

    phylumclassasvsChandubi %Deepor %
    RotiferaEurotatoria2229.1224.62
    415.5619.59
    ArthropodaArachnida21.410.75
    PhaeophyceaePhaeophyceae20.360.3
    PoriferaDemospongiae10.140.2
    ArthropodaOstracoda10.090.18

    results/10_protein/COI_protein_by_group.tsv

    Output files

  12. Step 11

    A rotifer signal of nutrient levels

    In plain wordsRotifers are useful messengers. A classic rule of thumb compares two rotifer genera: Brachionus, which thrives in nutrient-rich water, and Trichocerca, more typical of cleaner water. Dividing the number of Brachionus species by the number of Trichocerca species gives the QB/T index: below 1 suggests nutrient-poor water, 1–2 moderate, above 2 nutrient-rich.

    Few COI sequences can be named to species, so “species” here are clusters of sequences that are at least 97% identical — a standard stand-in for species with this barcode.

    5QB/T Chandubi
    5QB/T Deepor
    42 / 39rotifer species-clusters (Chandubi / Deepor)

    Both lakes score well above 2 — a nutrient-rich (eutrophic) signal in both, with about 10 Brachionus clusters against 2 Trichocerca. Treat the exact value as provisional: several genus names come only from distant GenBank matches (80–90%).

    Number of species-level clusters in each rotifer genus, per lake.
    Number of species-level clusters in each rotifer genus, per lake.
    For specialists — scripts, parameters & full tables

    COI ASVs clustered at 97% with VSEARCH --cluster_size (abundance-sorted). MOTU genus from the reference-library genus of any member ASV, otherwise the genus of the best NCBI BLAST hit at ≥ 80% identity (provisional). QB/T after Sládeček (1983).

    Script scripts/11_rotifer_index.sh
    #!/usr/bin/env bash
    # Step 11 — Rotifer trophic index (QB/T, Sládeček 1983): number of Brachionus species divided
    # by number of Trichocerca species. Because few COI sequences can be named to species, species
    # are approximated by MOTUs: COI ASVs clustered at 97 % identity (a common COI species proxy).
    set -euo pipefail
    cd "$(dirname "$0")/.."
    
    OUT=results/11_rotifer_index
    mkdir -p "$OUT"
    
    # Abundance-sorted clustering needs ;size= annotations: use total reads per ASV
    awk -F'\t' 'NR==FNR { if (FNR > 1) size[$1] = $2 + $3; next }
                /^>/ { id = substr($1, 2); print ">" id ";size=" size[id]; next } { print }' \
      results/04_curation/COI_asv_table_curated.tsv results/04_curation/COI_asv_seqs_curated.fasta \
      > work/coi_sized.fasta
    vsearch --cluster_size work/coi_sized.fasta --id 0.97 --sizein --strand plus \
      --uc "$OUT/COI_motu97.uc" --quiet
    
    Rscript scripts/11_rotifer_index.R
    

    View on GitHub

    Script scripts/11_rotifer_index.R
    # Step 11 — Rotifer genera and the Brachionus:Trichocerca quotient (QB/T) per lake.
    # Genus of each 97 % MOTU: the reference-library genus of any member ASV; otherwise the genus
    # of the best NCBI BLAST hit when identity >= 80 % ("provisional" — a related genus is possible).
    # QB/T interpretation (Sládeček 1983): < 1 oligotrophic, 1–2 mesotrophic, > 2 eutrophic.
    dir.create("work/plots", recursive = TRUE, showWarnings = FALSE)
    suppressPackageStartupMessages({ library(data.table); library(ggplot2) })
    
    out <- "results/11_rotifer_index"
    uc <- fread(file.path(out, "COI_motu97.uc"), header = FALSE, sep = "\t")
    uc <- uc[V1 %in% c("S", "H"), .(asv = sub(";size=.*", "", V9),
                                    centroid = sub(";size=.*", "", fifelse(V1 == "S", V9, V10)))]
    grp <- fread("results/09_ecology/COI_asv_groups.tsv", sep = "\t", quote = "", na.strings = c("", "NA"))
    bl <- fread("results/06_taxonomy/COI_blast_unknowns_summary.tsv", sep = "\t", quote = "", na.strings = c("", "NA"))
    bl[, blast_genus := fifelse(ncbi_identity >= 80 & !grepl("environmental|sample", ncbi_best_hit),
                                sub(" .*", "", ncbi_best_hit), NA_character_)]
    
    d <- merge(uc, grp, by = "asv")
    d <- merge(d, bl[, .(asv, blast_genus)], by = "asv", all.x = TRUE)
    motu <- d[, .(W1 = sum(W1), W2 = sum(W2), asvs = .N,
                  rotifer = any(group == "Rotifers"),
                  genus = na.omit(c(Genus, blast_genus))[1],
                  genus_source = fcase(any(!is.na(Genus)), "reference library",
                                       any(!is.na(blast_genus)), "BLAST (provisional)",
                                       default = NA_character_)), by = centroid]
    fwrite(motu[order(-(W1 + W2))], file.path(out, "COI_motus.tsv"), sep = "\t")
    
    rot <- motu[rotifer == TRUE]
    by_genus <- rbindlist(lapply(c("W1", "W2"), function(s) {
      x <- rot[get(s) > 0]
      x[, .(motus = .N, reads = sum(get(s)), source = paste(sort(unique(na.omit(genus_source))), collapse = "; ")),
        by = .(genus = fifelse(is.na(genus), "unassigned rotifer", genus))][, sample := s][]
    }))
    setcolorder(by_genus, c("sample", "genus"))
    fwrite(by_genus[order(sample, -motus)], file.path(out, "rotifer_genera_by_lake.tsv"), sep = "\t")
    
    qbt <- rbindlist(lapply(c("W1", "W2"), function(s) {
      x <- rot[get(s) > 0]
      b <- sum(x$genus %in% "Brachionus"); t <- sum(x$genus %in% "Trichocerca")
      data.table(sample = s, rotifer_motus = nrow(x), brachionus_motus = b, trichocerca_motus = t,
                 QBT = if (t > 0) round(b / t, 2) else NA_real_,
                 reading = if (t == 0) "undefined (no Trichocerca)" else
                   fcase(b / t < 1, "oligotrophic", b / t <= 2, "mesotrophic", default = "eutrophic"))
    }))
    fwrite(qbt, file.path(out, "QBT_index.tsv"), sep = "\t")
    
    site_of <- c(W1 = "Chandubi (W1)", W2 = "Deepor Beel (W2)")
    pg <- by_genus[genus != "unassigned rotifer"]
    pg[, site := site_of[sample]]
    pg[, genus := factor(genus, levels = pg[, sum(motus), by = genus][order(V1), genus])]
    p <- ggplot(pg, aes(motus, genus, fill = site)) +
      geom_col(position = position_dodge(width = 0.75), width = 0.7) +
      geom_text(aes(label = motus), position = position_dodge(width = 0.75), hjust = -0.3, size = 3.3) +
      scale_fill_manual(values = c("#2b6c8f", "#c2662d")) +
      scale_x_continuous(expand = expansion(mult = c(0, 0.12))) +
      labs(x = "Species-level clusters (97 % MOTUs)", y = NULL, fill = NULL,
           title = "Rotifer genera in each lake (COI)",
           subtitle = sprintf("QB/T — Chandubi: %s · Deepor: %s  (<1 oligotrophic, 1–2 mesotrophic, >2 eutrophic)",
                              qbt[sample == "W1", QBT], qbt[sample == "W2", QBT])) +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom", plot.title.position = "plot")
    saveRDS(p, "work/plots/11_rotifer_genera.rds")
    ggsave("docs/img/11_rotifer_genera.png", p, width = 9, height = 5, dpi = 120)
    print(qbt); print(by_genus[order(sample, -motus)])
    

    View on GitHub

    samplerotifer_motusbrachionus_motustrichocerca_motusQBTreading
    W1421025eutrophic
    W2391025eutrophic

    results/11_rotifer_index/QBT_index.tsv

    samplegenusmotusreadssource
    W1unassigned rotifer1731,442
    W1Brachionus1022,056BLAST (provisional); reference library
    W1Polyarthra53,243BLAST (provisional); reference library
    W1Keratella34,165reference library
    W1Trichocerca23,893BLAST (provisional)
    W1Plationus14,934BLAST (provisional)
    W1Anuraeopsis1768BLAST (provisional)
    W1Epiphanes1287BLAST (provisional)
    W1Mytilina1978BLAST (provisional)
    W1Asplanchna150reference library
    W2unassigned rotifer1421,313
    W2Brachionus1028,513BLAST (provisional); reference library
    W2Polyarthra54,477BLAST (provisional); reference library
    W2Keratella38,625reference library
    W2Trichocerca21,694BLAST (provisional)
    W2Plationus11,278BLAST (provisional)
    W2Anuraeopsis1262BLAST (provisional)
    W2Epiphanes1308BLAST (provisional)
    W2Mytilina1439BLAST (provisional)
    W2Asplanchna117reference library

    results/11_rotifer_index/rotifer_genera_by_lake.tsv

    Output files

  13. Steps 12, 13, 16

    Files for the paper

    In plain wordsEverything above, packaged the way a journal and the public DNA archive expect it: figures at print resolution, tables, a draft Methods section, and the forms needed to deposit the raw data.

    Manuscript package

    NCBI SRA submission

    Registered under BioProject PRJNA1551452 (BioSamples SAMN64467025, SAMN64467026).

    Two BioSamples (one per lake) and four sequencing runs (two barcodes × two lakes). Because both barcodes use the same file names, the eight files get unique names for upload (e.g. COI_Deepor_Beel_W2_R1.fastq.gz).

    For specialists — scripts, parameters & full tables
    Script scripts/12_manuscript.R
    # Step 12 — Manuscript package (re-run after step 15 to include the microscopy comparison): multi-panel figures (300 dpi PNG + vector PDF), main tables
    # (CSV) and one supplementary workbook (XLSX). Reads only the outputs of steps 1–11.
    suppressPackageStartupMessages({ library(data.table); library(ggplot2); library(patchwork); library(openxlsx) })
    
    out <- "results/12_manuscript"
    dir.create(file.path(out, "figures"), recursive = TRUE, showWarnings = FALSE)
    dir.create(file.path(out, "tables"), recursive = TRUE, showWarnings = FALSE)
    rd <- function(p) fread(p, sep = "\t", quote = "", na.strings = c("", "NA"))
    pl <- function(n) readRDS(file.path("work/plots", paste0(n, ".rds")))
    # Environmental Monitoring and Assessment artwork rules: no titles inside figures, sans-serif
    # lettering 8–12 pt (Liberation Sans = Arial metrics), width <= 174 mm, height <= 234 mm,
    # combination art (plots with text) as TIFF at 600 dpi; files named Fig1, Fig2, ...
    FONT <- "Liberation Sans"
    pub <- theme(plot.title = element_blank(), plot.subtitle = element_blank(),
                 text = element_text(family = FONT, size = 9), axis.text = element_text(size = 8),
                 legend.text = element_text(size = 8), strip.text = element_text(size = 9),
                 plot.tag = element_text(size = 11, face = "bold"))
    save_fig <- function(p, n, h_mm, w_mm = 174) {
      stopifnot(h_mm <= 234, w_mm <= 174)
      f <- file.path(out, "figures", paste0("Fig", n))
      ggsave(paste0(f, ".tif"), p, width = w_mm, height = h_mm, units = "mm", dpi = 600,
             device = function(filename, width, height, res, ...)
               grDevices::tiff(filename, width = width, height = height, units = "in", res = res,
                               compression = "lzw", type = "cairo", family = FONT))
      ggsave(paste0(f, ".png"), p, width = w_mm, height = h_mm, units = "mm", dpi = 300, bg = "white")
      ggsave(paste0(f, ".pdf"), p, width = w_mm, height = h_mm, units = "mm", device = cairo_pdf, family = FONT)
    }
    unlink(list.files(file.path(out, "figures"), full.names = TRUE))
    
    # ---- Figures ----
    # Numbered in order of first citation in the manuscript (step 16)
    save_fig((pl("05_read_tracking") + pub) / (pl("05_rarefaction") + pub) + plot_annotation(tag_levels = "a"), 1, 185)
    save_fig(pl("10_protein") + pub, 2, 160)
    save_fig((pl("09_18S_roles") + pub) / (pl("09_COI_groups") + pub) + plot_annotation(tag_levels = "a"), 3, 165)
    save_fig(pl("09_genera_heatmap") + pub, 4, 220)
    save_fig((pl("08_alpha") + pub) / (pl("08_overlap") + pub) + plot_layout(heights = c(1, 1.2)) +
               plot_annotation(tag_levels = "a"), 5, 210)
    save_fig(pl("11_rotifer_genera") + pub, 6, 100)
    save_fig(pl("15_microscopy") + pub, 7, 85)
    
    # ---- Table 1: sequencing and processing summary ----
    tr <- rd("results/05_summary/read_tracking.tsv")
    asv <- rbindlist(lapply(c("18S", "COI"), function(m) {
      d <- rd(sprintf("results/04_curation/%s_asv_table_curated.tsv", m))
      data.table(marker = m, sample = c("W1", "W2"), ASVs = c(sum(d$W1 > 0), sum(d$W2 > 0)))
    }))
    t1 <- merge(tr, asv, by = c("marker", "sample"))
    t1[, site := fifelse(sample == "W1", "Chandubi Beel", "Deepor Beel")]
    t1 <- t1[, .(Marker = marker, Site = site, `Raw read pairs` = raw, `Primers found` = primers_found,
                 `Quality-filtered` = filtered, Merged = merged, `Non-chimeric` = nonchim,
                 `Curated` = curated, `% retained` = pct_retained, ASVs)]
    fwrite(t1, file.path(out, "tables", "Table1_sequencing_summary.csv"))
    
    # ---- Table 2: diversity ----
    al <- rd("results/08_diversity/alpha_diversity.tsv")
    t2 <- al[, .(Marker = marker, Site = fifelse(sample == "W1", "Chandubi Beel", "Deepor Beel"),
                 `Rarefied depth` = rarefied_to, `Observed ASVs` = round(observed),
                 Shannon = round(shannon, 2), `Simpson (1-D)` = round(simpson, 3),
                 `Pielou J'` = round(pielou, 2), `Hill q1` = round(hill_q1, 1), `Hill q2` = round(hill_q2, 1))]
    ov <- rd("results/08_diversity/overlap_by_level.tsv")
    fwrite(t2, file.path(out, "tables", "Table2_alpha_diversity.csv"))
    fwrite(ov, file.path(out, "tables", "Table3_overlap_between_lakes.csv"))
    
    # ---- Table 4: named taxa ----
    nm <- rd("results/06_taxonomy/named_genera_species.tsv")
    t4 <- nm[, .(Marker = marker, Group = group, Genus, Species = fifelse(is.na(Species), "", Species),
                 ASVs = asvs, `Chandubi (% reads)` = W1_pct, `Deepor (% reads)` = W2_pct,
                 `Best identity (%)` = best_identity)][order(Marker, Group, Genus)]
    fwrite(t4, file.path(out, "tables", "Table4_named_taxa.csv"))
    fwrite(rd("results/09_ecology/indicator_taxa.tsv"), file.path(out, "tables", "Table5_indicator_taxa.csv"))
    
    # ---- Supplementary workbook ----
    seqs <- function(m) {
      x <- rd(sprintf("results/04_curation/%s_asv_table_curated.tsv", m))[, .(asv, sequence)]
      merge(rd(sprintf("results/06_taxonomy/%s_taxonomy.tsv", m)), x, by = "asv")
    }
    sheets <- list(
      S1_18S_ASVs = seqs("18S"),
      S2_COI_ASVs = merge(seqs("COI"), rd("results/09_ecology/COI_asv_groups.tsv")[, .(asv, group)], by = "asv"),
      S3_read_tracking = tr,
      S4_COI_BLASTn_unknowns = rd("results/06_taxonomy/COI_blast_unknowns_summary.tsv"),
      S5_18S_ecological_roles = rd("results/09_ecology/18S_ecological_roles.tsv"),
      S6_COI_animal_groups = rd("results/09_ecology/COI_animal_groups.tsv"),
      S7_animal_genera_by_marker = rd("results/09_ecology/animal_genera_by_marker.tsv"),
      S8_rotifer_MOTUs = rd("results/11_rotifer_index/COI_motus.tsv"),
      S9_QBT_index = rd("results/11_rotifer_index/QBT_index.tsv"))
    sheets$S10_COI_protein_search <- rd("results/10_protein/COI_protein_summary.tsv")
    sheets$S11_Deepor_rotifers_vs_micro <- rd("results/15_microscopy/rotifers_microscopy_vs_DNA.tsv")
    sheets$S12_Deepor_algae_vs_micro <- rd("results/15_microscopy/algae_genera_microscopy_vs_DNA.tsv")
    wb <- createWorkbook()
    for (n in names(sheets)) {
      addWorksheet(wb, n)
      writeData(wb, n, sheets[[n]], headerStyle = createStyle(textDecoration = "bold"))
      freezePane(wb, n, firstRow = TRUE)
      setColWidths(wb, n, cols = seq_len(ncol(sheets[[n]])), widths = "auto")
    }
    saveWorkbook(wb, file.path(out, "tables", "Supplementary_Tables_S1-S12.xlsx"), overwrite = TRUE)
    
    # ASV sequences for deposition alongside the paper
    for (m in c("18S", "COI"))
      file.copy(sprintf("results/04_curation/%s_asv_seqs_curated.fasta", m),
                file.path(out, "tables", sprintf("Supplementary_%s_ASVs.fasta", m)), overwrite = TRUE)
    print(list.files(out, recursive = TRUE))
    

    View on GitHub

    Script scripts/16_manuscript_docx.js
    // Step 16 — Build the manuscript as a Word file formatted for Environmental Monitoring and Assessment
    // (Springer): 10-pt Times, continuous line numbers, automatic page numbers, unnumbered headings
    // (level 1 bold, level 2 normal), figures embedded with "Fig." captions, Word tables, APA references.
    // Numbers are read from results/; references from the Crossref records of step 14.
    // Markup in text: *italic*, [[Key1;Key2]] = parenthetical citation, [[@Key]] = narrative citation.
    // Run: NODE_PATH=work/node/node_modules node scripts/16_manuscript_docx.js
    const fs = require("fs");
    const path = require("path");
    const {
      Document, Packer, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell, WidthType, AlignmentType,
      Footer, PageNumber, LineNumberRestartFormat, BorderStyle, ShadingType, HeadingLevel,
    } = require("docx");
    
    const ROOT = path.resolve(__dirname, "..");
    const R = (p) => path.join(ROOT, p);
    const tsv = (p) => {
      const [h, ...rows] = fs.readFileSync(R(p), "utf8").trim().split("\n").map((l) => l.split("\t"));
      return rows.map((r) => Object.fromEntries(h.map((k, i) => [k, r[i]])));
    };
    const num = (x, d = 1) => Number(x).toFixed(d);
    const int = (x) => Math.round(Number(x)).toLocaleString("en-US");
    
    // ---------------------------------------------------------------- data
    const track = Object.fromEntries(tsv("results/05_summary/read_tracking.tsv").map((r) => [`${r.marker}_${r.sample}`, r]));
    const alpha = Object.fromEntries(tsv("results/08_diversity/alpha_diversity.tsv").map((r) => [`${r.marker}_${r.sample}`, r]));
    const ovl = Object.fromEntries(tsv("results/08_diversity/overlap_by_level.tsv").map((r) => [`${r.marker}_${r.level}`, r]));
    const roles = Object.fromEntries(tsv("results/09_ecology/18S_ecological_roles.tsv").map((r) => [r.role, r]));
    const cgrp = Object.fromEntries(tsv("results/09_ecology/COI_animal_groups.tsv").map((r) => [r.group, r]));
    const qbt = Object.fromEntries(tsv("results/11_rotifer_index/QBT_index.tsv").map((r) => [r.sample, r]));
    const tsum = Object.fromEntries(tsv("results/06_taxonomy/taxonomy_summary.tsv").map((r) => [r.marker, r]));
    const depth = Object.fromEntries(tsv("results/06_taxonomy/assignment_depth.tsv").map((r) => [`${r.marker}_${r.rank}`, r]));
    const prot = tsv("results/10_protein/COI_protein_summary.tsv");
    const protRot = tsv("results/10_protein/COI_protein_by_group.tsv").find((r) => r.consensus_phylum === "Rotifera");
    const top = tsv("results/06_taxonomy/COI_blast_unknowns_summary.tsv").find((r) => r.asv === "COI_ASV0001");
    const topProt = prot.find((r) => r.asv === "COI_ASV0001");
    const micro = tsv("results/15_microscopy/comparison_summary.tsv");
    const alg = tsv("results/15_microscopy/algae_genera_microscopy_vs_DNA.tsv");
    const animalMark = tsv("results/09_ecology/animal_genera_by_marker.tsv");
    const ind = tsv("results/09_ecology/indicator_taxa.tsv");
    const dada = { "18S": JSON.parse(fs.readFileSync(R("results/03_dada2/18S/summary.json"))),
                   COI: JSON.parse(fs.readFileSync(R("results/03_dada2/COI/summary.json"))) };
    const cur = tsv("results/04_curation/curation_summary.tsv");
    const refs = JSON.parse(fs.readFileSync(R("results/14_references/references_records.json")));
    
    const rawTotal = Object.values(track).reduce((a, r) => a + Number(r.raw), 0);
    const algae = (s) => ["Green algae", "Desmids & conjugating algae", "Diatoms", "Golden & other algae", "Dinoflagellates"]
      .reduce((a, k) => a + Number(roles[k][s]), 0);
    const curRemoved = (m) => {
      const rows = cur.filter((r) => r.marker === m);
      const kept = rows.find((r) => r.reason === "kept");
      const rem = rows.filter((r) => r.reason !== "kept");
      const remReads = rem.reduce((a, r) => a + Number(r.W1_reads) + Number(r.W2_reads), 0);
      const all = remReads + Number(kept.W1_reads) + Number(kept.W2_reads);
      return { asvs: rem.reduce((a, r) => a + Number(r.asvs), 0), pct: (100 * remReads) / all };
    };
    const indv = (name) => ind.find((r) => r.name === name);
    const mic = (label) => micro.find((r) => r.comparison.startsWith(label));
    const algBoth = alg.filter((r) => r.found_by === "both").length;
    const algDnaOnly = alg.filter((r) => r.found_by === "DNA only").length;
    const algBlind = alg.filter((r) => r.found_by === "microscopy only" && r.note).length;
    const nBy = (k) => animalMark.filter((r) => r.detected_by === k).length;
    const protRotN = Number(protRot.asvs);
    
    // ---------------------------------------------------------------- citations
    const refName = (k) => {
      const r = refs[k];
      if (!r) throw new Error(`unknown reference ${k}`);
      const a = r.authors.map((x) => x.family);
      const who = a.length === 1 ? a[0] : a.length === 2 ? `${a[0]} & ${a[1]}` : `${a[0]} et al.`;
      return { who, year: r.year };
    };
    const EXTRA = { Oksanen: { who: "Oksanen et al.", year: 2025 }, RCore: { who: "R Core Team", year: 2026 } };
    const citeOne = (k) => EXTRA[k] || refName(k);
    function expand(text) {
      return text.replace(/\[\[(@?)([^\]]+)\]\]/g, (_, narrative, keys) => {
        const items = keys.split(";").map((k) => citeOne(k.trim()));
        if (narrative) return items.map((c) => `${c.who} (${c.year})`).join("; ");
        return "(" + items.map((c) => `${c.who}, ${c.year}`).join("; ") + ")";
      });
    }
    
    // ---------------------------------------------------------------- paragraph helpers
    const FONT = "Times New Roman";
    const SIZE = 20; // half-points: 10 pt
    function runs(text, base = {}) {
      const out = [];
      expand(text).split(/(\*\*[^*]+\*\*|\*[^*]+\*)/).forEach((seg) => {
        if (!seg) return;
        if (seg.startsWith("**")) out.push(new TextRun({ text: seg.slice(2, -2), bold: true, font: FONT, size: SIZE, ...base }));
        else if (seg.startsWith("*")) out.push(new TextRun({ text: seg.slice(1, -1), italics: true, font: FONT, size: SIZE, ...base }));
        else out.push(new TextRun({ text: seg, font: FONT, size: SIZE, ...base }));
      });
      return out;
    }
    const P = (text, opts = {}) => new Paragraph({ children: runs(text), spacing: { after: 120, line: 360 },
      alignment: AlignmentType.JUSTIFIED, ...opts });
    const H1 = (t) => new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: t, bold: true, font: FONT, size: 22 })],
      spacing: { before: 240, after: 240 } });
    const H2 = (t) => new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: t, font: FONT, size: SIZE, italics: true })],
      spacing: { before: 200, after: 200 } });
    
    const figs = [];
    function FIG(n, caption) {
      const file = R(`results/12_manuscript/figures/Fig${n}.png`);
      const buf = fs.readFileSync(file);
      const w = buf.readUInt32BE(16), h = buf.readUInt32BE(20);
      const width = 640; // ~6.7 in at 96 px/in, within the 174-mm column
      figs.push(n);
      return [
        new Paragraph({ children: [new ImageRun({ type: "png", data: buf, transformation: { width, height: Math.round((width * h) / w) },
          altText: { title: `Fig. ${n}`, description: caption.replace(/\*/g, ""), name: `Fig${n}` } })],
          alignment: AlignmentType.CENTER, spacing: { before: 200, after: 80 } }),
        new Paragraph({ children: [new TextRun({ text: `Fig. ${n} `, bold: true, font: FONT, size: SIZE }), ...runs(caption)],
          spacing: { after: 240, line: 300 } }),
      ];
    }
    
    const BORDER = { style: BorderStyle.SINGLE, size: 4, color: "000000" };
    const NONE = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
    function TABLE(n, caption, header, rows, widths, note) {
      const total = widths.reduce((a, b) => a + b, 0);
      const cell = (t, i, isHead, last) => new TableCell({
        width: { size: widths[i], type: WidthType.DXA },
        borders: { top: isHead ? BORDER : NONE, bottom: isHead || last ? BORDER : NONE, left: NONE, right: NONE },
        children: [new Paragraph({ children: runs(String(t), { size: 18, bold: isHead }),
          alignment: i === 0 ? AlignmentType.LEFT : AlignmentType.RIGHT })],
        margins: { top: 40, bottom: 40, left: 80, right: 80 },
      });
      const out = [
        new Paragraph({ children: [new TextRun({ text: `Table ${n} `, bold: true, font: FONT, size: SIZE }), ...runs(caption)],
          spacing: { before: 240, after: 80, line: 300 } }),
        new Table({ width: { size: total, type: WidthType.DXA }, columnWidths: widths,
          rows: [new TableRow({ tableHeader: true, children: header.map((t, i) => cell(t, i, true, false)) }),
                 ...rows.map((r, j) => new TableRow({ children: r.map((t, i) => cell(t, i, false, j === rows.length - 1)) }))] }),
      ];
      if (note) out.push(new Paragraph({ children: runs(note, { size: 16 }), spacing: { before: 60, after: 240 } }));
      return out;
    }
    
    // ---------------------------------------------------------------- content
    const C = "Chandubi Beel", D = "Deepor Beel";
    const a = (m, s, k, d = 1) => num(alpha[`${m}_${s}`][k], d);
    const t = (m, s) => track[`${m}_${s}`];
    
    const TITLE = "Multi-marker eDNA metabarcoding reveals contrasting micro-eukaryote communities and a shared rotifer assemblage in two Brahmaputra floodplain wetlands of Assam, India";
    
    const abstract = `Floodplain wetlands of the Brahmaputra valley are under growing urban pressure, yet molecular baselines of their plankton are lacking. We characterised the plankton of Deepor Beel, a Ramsar wetland bordering the municipal solid-waste dumping ground of Guwahati, and the less disturbed Chandubi Beel using environmental DNA metabarcoding of two markers: the 18S rRNA V4 region for micro-eukaryotes and the mitochondrial cytochrome c oxidase I (COI) gene for metazoans. From ${int(rawTotal)} read pairs, joint denoising yielded ${int(tsum["18S"].asvs)} 18S and ${int(tsum.COI.asvs)} COI amplicon sequence variants. The micro-eukaryote communities were almost distinct (${ovl["18S_ASV"].pct_shared}% of variants shared): Chandubi Beel was dominated by green algae, diatoms and free-living protozoa, whereas Deepor Beel was richer (${int(alpha["18S_W2"].observed)} vs ${int(alpha["18S_W1"].observed)} variants) and dominated by fungi (${num(roles.Fungi.W2, 0)}% of reads), oomycetes and desmids. In contrast, ${ovl["COI_ASV"].pct_shared}% of COI variants occurred in both wetlands, where rotifers formed ${num(cgrp.Rotifers.W1, 0)}–${num(cgrp.Rotifers.W2, 0)}% of reads. Protein-level searches placed ${protRotN} of the abundant unassigned COI variants among rotifers, revealing many rotifer lineages absent from reference libraries. The rotifer quotient QB/T of ${qbt.W1.QBT} in both wetlands and the enrichment of *Brachionus* and *Keratella tropica* at Deepor Beel indicate nutrient-rich conditions. DNA detections agreed with published microscopy for the dominant desmid and rotifer genera. These results provide the first molecular plankton baseline for both wetlands and show that combining markers is essential for wetland biomonitoring in north-eastern India.`;
    
    const keywords = "Biomonitoring · Ramsar site · Zooplankton · Phytoplankton · Desmids · Cytochrome c oxidase I";
    
    const intro = [
      `Wetland plankton respond within weeks to changes in nutrient load, organic matter and hydrology, which makes them sensitive indicators of ecological condition. Identifying them by microscopy is, however, slow and demands separate expertise for rotifers, crustaceans, protists and algae, while small and fragile forms are routinely overlooked. Environmental DNA (eDNA) metabarcoding offers a complementary route: DNA shed into the water is amplified with taxonomically informative markers and sequenced in a single assay, and its use for monitoring Indian fresh waters is growing [[Sahu2025]].`,
      `No single marker recovers a whole plankton community. Mitochondrial cytochrome c oxidase I (COI) resolves metazoan zooplankton to species where reference sequences exist, whereas the nuclear 18S rRNA gene captures the broader eukaryotic assemblage — algae, protists and fungi — usually at coarser taxonomic ranks. Studies that combine markers consistently detect more taxa than either marker alone [[Zhang2018;Stefanni2018;Sildever2021;Zhao2021]], and eDNA surveys have linked shifts in plankton composition to anthropogenic pressure in lakes and reservoirs [[Lin2023;Jo2026]]. The reliability of species-level assignments nevertheless depends on reference databases, which remain incomplete for tropical freshwater invertebrates [[Chen2025]].`,
      `Deepor Beel is a Ramsar-listed floodplain wetland of the Brahmaputra on the south-western edge of Guwahati, the largest city of north-eastern India. It lies immediately beside the city's municipal solid-waste dumping ground, which exposes it to leachate and organic enrichment in addition to urban encroachment. Chandubi Beel, a natural lake in the same district, is comparatively undisturbed. The rotifers and algae of Deepor Beel have been described by microscopy [[Sharma2010;Baruah2020]], but no molecular survey exists for either wetland.`,
      `Here we provide a multi-marker eDNA baseline for the plankton of both wetlands. We asked (i) how the micro-eukaryote and metazoan communities of the pressured and the reference wetland differ, (ii) what biological indicators reveal about their trophic state in the absence of chemical monitoring, (iii) how much of the community can be named with current reference libraries, and (iv) whether DNA detections agree with earlier microscopy.`,
    ];
    
    const methods = [
      ["Study area and sampling",
       `Surface water was collected on 29 March 2026 at one near-shore location in each wetland: Deepor Beel (W2; 26.10953° N, 91.70818° E) and Chandubi Beel (W1; 25.881015° N, 91.422898° E), Kamrup district, Assam, India. The study was designed as a biological baseline and did not include water chemistry; the contrast between the wetlands is defined by their setting — Deepor Beel adjoining the municipal solid-waste dumping ground of Guwahati and Chandubi Beel without comparable pressure — and the trophic state of each is inferred from biological indicators (see below).`],
      ["DNA extraction, amplification and sequencing",
       `DNA extraction, PCR amplification and library preparation were carried out by Eurofins Genomics following the provider's standard amplicon protocol. Two markers were amplified: the V4 region of the 18S rRNA gene with primers E572F (5′-CYGCGGTAATTCCAGCTC-3′) and E1009R (5′-AYGGTATCTRWTCRTCTTYG-3′) [[Comeau2011]], and the 313-bp "Leray" fragment of COI with mlCOIintF (5′-GGWACWGGWTGAACWGTWTAYCCYCC-3′) [[Leray2013]] and jgHCO2198 (5′-TAIACYTCIGGRTGICCRAARAAYCA-3′) [[Geller2013]]. Libraries were sequenced on an Illumina MiSeq (2 × 300 bp). The identity of both primer pairs was confirmed directly from the reads.`],
      ["Read processing",
       `Read integrity was verified against the provider's MD5 checksums and quality assessed with FastQC v0.13.0 and MultiQC v1.35 [[Ewels2016]]. Primers were removed with Cutadapt v5.2 [[Martin2011]] using linked adapters: the 5′ primer was required at the start of each read (maximum error rate 0.15), the reverse-complemented opposite primer was trimmed when present, and pairs lacking either primer were discarded. Reads were then processed per marker with DADA2 v1.38.0 [[Callahan2016]] in R v4.5.3 [[RCore]]: truncation at ${dada["18S"].truncLen.join("/")} bp (18S) and ${dada.COI.truncLen.join("/")} bp (COI), maxEE 2/4, maxN 0, error models learned per read direction, joint (pooled) denoising of both samples so that identical sequences received the same amplicon sequence variant (ASV) in both wetlands, merging with a minimum overlap of 12 bp and no mismatches, and consensus chimera removal. COI ASVs were retained only if their length differed from 313 bp by whole codons and they translated without stop codons under the invertebrate mitochondrial code (NCBI table 5); 18S ASVs were retained if 350–480 bp long.`],
      ["Taxonomic assignment",
       `ASVs were classified with the SINTAX algorithm [[Edgar2016]] in VSEARCH v2.32.0 [[Rognes2016]] against PR2 v5.1.1 for 18S [[Guillou2013]] and the Eukaryote CO1 Reference Set v5.1.0 for COI [[Porter2018]]. A rank was accepted only if its bootstrap support was ≥ 0.80 and the identity to the closest reference sequence reached a rank-specific minimum (COI: order 85%, family 90%, genus 95%, species 97%; 18S: family 95%, genus 97%, species 99%). The 40 most abundant COI ASVs with < 85% identity to the reference set were searched against NCBI core_nt with BLASTn; hits below 80% identity were not used. ASVs among these without a ≥ 90% DNA match were further compared at the protein level: the reference set was translated with SeqKit v2.14.0 [[Shen2016]] and searched with DIAMOND v2.2.8 blastx [[Buchfink2021]] (ultra-sensitive mode, invertebrate mitochondrial code, E ≤ 10⁻¹⁰). A consensus phylum was assigned when at least three hits lay within 95% of the best bit score and ≥ 70% of them agreed.`],
      ["Community analyses",
       `Diversity analyses used vegan v2.7.5 [[Oksanen]]. Alpha diversity was computed after rarefying both samples of each marker to the shallower depth, averaged over 100 random subsamples, and reported as observed ASVs, Shannon, Simpson and Pielou indices and Hill numbers (q1 = exp H′; q2 = 1/D). Overlap between wetlands was summarised as shared and unique ASVs, genera and species with Jaccard similarity and Bray–Curtis dissimilarity. 18S ASVs were grouped into ecological roles from their PR2 division and class, and COI ASVs into animal groups. For the rotifer quotient QB/T [[Sladecek1983]], COI ASVs were clustered into species-level operational units at 97% identity with VSEARCH and *Brachionus* and *Trichocerca* units counted per wetland. DNA detections for Deepor Beel were compared with the taxa reported by microscopy for rotifers [[Sharma2010]] and algae [[Baruah2020]]. With one sample per wetland, no inferential statistics were applied; all contrasts are descriptive. Bioinformatic scripts and draft text were prepared with the assistance of a large language model (Claude, Anthropic); all code, results and text were checked by the authors. All scripts are publicly available (see Data availability).`],
    ];
    
    const results = [
      ["Sequencing depth and data processing", [
        `Sequencing produced ${int(rawTotal)} read pairs, of which 87.8–89.6% carried both primers (Table 1). After denoising, merging, chimera removal (${num(dada["18S"].chimeric_read_pct)}% of 18S and ${num(dada.COI.chimeric_read_pct)}% of COI reads) and curation, ${num(t("18S", "W1").pct_retained)}–${num(t("18S", "W2").pct_retained)}% (18S) and ${num(t("COI", "W1").pct_retained)}–${num(t("COI", "W2").pct_retained)}% (COI) of raw pairs were retained. Curation removed few reads (COI: ${curRemoved("COI").asvs} ASVs, ${num(curRemoved("COI").pct)}% of reads; 18S: ${curRemoved("18S").asvs} ASVs, ${num(curRemoved("18S").pct)}%), and the retained COI ASVs clustered at 313 and 316 bp, as expected for a protein-coding fragment. Rarefaction curves reached a plateau for both markers in both wetlands (Fig. 1), indicating that sequencing depth was sufficient.`,
      ]],
      ["Taxonomic resolution and the reference-library gap", [
        `The 18S data were well covered by PR2: ${num(depth["18S_Division"].W1, 0)}–${num(depth["18S_Division"].W2, 0)}% of reads were named at division level and ${tsum["18S"].named_genera} genera were identified. COI resolution was much lower: only ${num(depth.COI_Phylum.W1, 0)}% (${C}) and ${num(depth.COI_Phylum.W2, 0)}% (${D}) of reads were named even to phylum, because ${num(tsum.COI.reads_below_85pct_identity, 0)}% of COI reads were less than 85% identical to any reference sequence. GenBank searches showed that many of these unknowns matched unnamed "rotifer environmental samples" or known rotifers at only 80–89% DNA identity. At the protein level, ${protRotN} of ${prot.length} such ASVs were placed among rotifers with 89–98% amino-acid identity to *Trichocerca*, *Euchlanis*, *Filinia*, *Lecane* and *Brachionus* (Fig. 2), together accounting for ${num(protRot.W1_pct, 0)}% and ${num(protRot.W2_pct, 0)}% of COI reads. The single most abundant COI variant (${num(top.reads_pct_W1, 0)}–${num(top.reads_pct_W2, 0)}% of reads in both wetlands) translated cleanly but was at most ${num(top.ncbi_identity, 0)}% identical to any GenBank sequence, and its protein fell between a nematode (${num(topProt.aa_identity, 0)}%) and stramenopile algae, so its identity remains unresolved. These results show that the gap lies in the reference libraries rather than in the data: north-eastern Indian rotifers are poorly represented, consistent with the limitations noted for freshwater invertebrates generally [[Chen2025]].`,
      ]],
      ["Community composition and ecological roles", [
        `The micro-eukaryote communities of the two wetlands differed in structure (Fig. 3a). In ${C}, algae formed ${num(algae("W1"), 0)}% of 18S reads, dominated by green algae (${num(roles["Green algae"].W1, 0)}%; *Desmodesmus*, *Chlamydomonas*, *Pteromonas*) and diatoms (${num(roles.Diatoms.W1, 0)}%; *Cyclotella*, *Aulacoseira*, *Nitzschia*), together with free-living protozoa (${num(roles["Protozoa (free-living)"].W1, 0)}%), notably the cercozoan *Neoheteromita*. In ${D}, algae formed a similar share (${num(algae("W2"), 0)}%) but were dominated by desmids (${num(roles["Desmids & conjugating algae"].W2, 0)}%; *Staurastrum*, *Cosmarium*, *Pleurotaenium*), and fungi (${num(roles.Fungi.W2, 0)}%) and oomycetes (${num(roles["Water moulds (oomycetes)"].W2, 0)}%) took a far larger share than in ${C} (${num(roles.Fungi.W1, 0)}% and ${num(roles["Water moulds (oomycetes)"].W1, 0)}%). The most abundant Deepor-specific genera were the oomycete *Aphanomyces*, the algal-parasitic chytrid *Rhizophydium* and the golden alga *Lepidochromonas* (Fig. 4). A micro-eukaryote community weighted towards decomposers and parasites is consistent with the organic input expected beside a solid-waste dumping ground.`,
        `In the COI data, rotifers dominated both wetlands (${num(cgrp.Rotifers.W1, 0)}% and ${num(cgrp.Rotifers.W2, 0)}% of reads after integrating reference and protein-level assignments; Fig. 3b), led by *Brachionus* and *Keratella*. Copepods, ostracods and insects together contributed less than 1% of COI reads, although copepods (*Mesocyclops*, *Microcyclops* and *Eucyclops*) were recovered by 18S.`,
      ]],
      ["Diversity and overlap between wetlands", [
        `The two markers gave opposite diversity rankings (Table 2, Fig. 5a). For 18S, ${D} was considerably richer (${int(alpha["18S_W2"].observed)} vs ${int(alpha["18S_W1"].observed)} ASVs at equal depth; Hill q1 ${a("18S", "W2", "hill_q1")} vs ${a("18S", "W1", "hill_q1")}). For COI, ${C} was richer and more even (${int(alpha.COI_W1.observed)} vs ${int(alpha.COI_W2.observed)} ASVs; q1 ${a("COI", "W1", "hill_q1")} vs ${a("COI", "W2", "hill_q1")}), reflecting the stronger dominance of a few rotifers in ${D}.`,
        `Overlap differed even more (Fig. 5b). Only ${ovl["18S_ASV"].pct_shared}% of 18S ASVs and ${ovl["18S_Genus"].pct_shared}% of named 18S genera occurred in both wetlands (Bray–Curtis dissimilarity ${num(ovl["18S_ASV"].bray_curtis_dissimilarity, 2)}), whereas ${ovl["COI_ASV"].pct_shared}% of COI ASVs were shared at similar relative abundance (Bray–Curtis ${num(ovl["COI_ASV"].bray_curtis_dissimilarity, 2)}). The wetlands therefore share largely the same rotifer assemblage while hosting distinct algal, protist and fungal communities. A shared rotifer pool is plausible for floodplain lakes of the same river system, as rotifers disperse readily through resting eggs and seasonal hydrological connection, whereas micro-eukaryote producers and decomposers respond more directly to local conditions such as organic loading and macrophyte cover.`,
      ]],
      ["Biological indicators of trophic state", [
        `Without chemical data, the trophic condition of the wetlands can be read from indicator taxa (Table 3). The rotifer quotient QB/T was ${qbt.W1.QBT} in both wetlands (${qbt.W1.brachionus_motus} *Brachionus* against ${qbt.W1.trichocerca_motus} *Trichocerca* species-level units; Fig. 6), well above the threshold of 2 for eutrophic water [[Sladecek1983]]; some genus assignments rest on distant matches and the value is therefore provisional. The eutrophy-associated *Brachionus* (${num(indv("Brachionus").chandubi_pct)}% vs ${num(indv("Brachionus").deepor_pct)}% of COI reads), *B. caudatus* (${num(indv("Brachionus_caudatus").chandubi_pct)}% vs ${num(indv("Brachionus_caudatus").deepor_pct)}%) and *Keratella tropica* (${num(indv("Keratella_tropica").chandubi_pct)}% vs ${num(indv("Keratella_tropica").deepor_pct)}%) were more abundant in ${D}, in line with organic enrichment from the adjacent dumping ground. ${C} nevertheless carried diatoms typical of enriched water — *Cyclotella meneghiniana* (${num(indv("Cyclotella_meneghiniana").chandubi_pct)}%), *Nitzschia palea* and *Aulacoseira granulata* — indicating that it too is nutrient-rich rather than pristine. The prominence of desmids in ${D} reflects its dense macrophyte cover, a habitat in which desmids thrive.`,
      ]],
      ["Agreement with microscopy", [
        `DNA detections for ${D} agreed well with earlier microscopy (Fig. 7). For algae, ${algBoth} of the ${mic("Algal").microscopy} genera listed by [[@Baruah2020]] were also found by 18S, including the dominant desmids *Cosmarium* and *Staurastrum*, and DNA added ${algDnaOnly} genera not listed. Of the genera found only by microscopy, ${algBlind} were cyanobacteria, which lack an 18S gene, or euglenoids, which are poorly amplified by V4 primers. For rotifers, DNA detected ${Number(mic("Rotifer genus").also_by_DNA_named) + Number(mic("Rotifer genus").also_by_DNA_provisional)} of the five quantitatively important genera and ${Number(mic("Rotifer family").also_by_DNA_named) + Number(mic("Rotifer family").also_by_DNA_provisional)} of the seven important families reported by [[@Sharma2010]], but only ${mic("Rotifer species").also_by_DNA_named} of the seven important species, again reflecting the reference-library gap rather than absence.`,
      ]],
      ["Complementarity of markers and limitations", [
        `Of the animal genera named, ${nBy("18S only")} were detected only by 18S (flatworms, gastrotrichs, oligochaetes and the copepods *Eucyclops* and *Microcyclops*), ${nBy("COI only")} only by COI (including *Brachionus*, *Keratella* and *Polyarthra*) and ${nBy("both")} by both. Neither marker alone would have described these wetlands adequately, supporting multi-marker designs for wetland monitoring [[Zhang2018;Stefanni2018;Zhao2021]]. Adding a phytoplankton-specific marker such as chloroplast 16S would further resolve the algal and cyanobacterial fraction [[Lemonnier2025]].`,
        `The study is a baseline with one sample per wetland, so differences are descriptive and cannot be tested statistically. Read proportions reflect DNA abundance, which is affected by copy number, body size and primer affinity, rather than biomass. Indicator interpretations are qualitative, and repeated, seasonal sampling with replication would be needed to quantify the effect of the dumping ground on Deepor Beel.`,
      ]],
    ];
    
    const conclusions = `This first molecular plankton baseline for Deepor Beel and Chandubi Beel shows two wetlands that share their rotifer zooplankton but differ sharply in their micro-eukaryote communities. Deepor Beel, beside the city's solid-waste dumping ground, carries a richer community dominated by fungi, oomycetes, desmids and decomposers, and a stronger signal of eutrophy-associated rotifers, while Chandubi Beel is dominated by green algae, diatoms and protozoa. Rotifer indicators point to nutrient-rich conditions in both wetlands. Many rotifer lineages and the most abundant metazoan sequence lack reference sequences, which identifies barcoding of the regional fauna as a priority. Combining 18S and COI markers, and checking detections against microscopy, provides a practical framework for monitoring these wetlands.`;
    
    const declarations = [
      ["Acknowledgements", "[Acknowledgements]"],
      ["Author contributions", "Sahil Seikh: conceptualisation, sampling, investigation, data analysis, writing – original draft. [Add other authors and contributions.]"],
      ["Funding", "[State funding sources, or: The authors did not receive support from any organization for the submitted work.]"],
      ["Data availability", "Raw sequence reads are deposited in the NCBI Sequence Read Archive under BioProject PRJNA1551452 (BioSamples SAMN64467025 and SAMN64467026). ASV sequences, ASV and taxonomy tables, and all analysis scripts are available at https://github.com/Angsumi/Sahil_Metagenome."],
      ["Declarations", "Ethics approval: Not applicable; the study used environmental water samples and involved no human participants or animals. Consent to participate: Not applicable. Consent for publication: Not applicable. Competing interests: [The authors declare no competing interests.]"],
    ];
    
    // ---------------------------------------------------------------- tables
    const table1Rows = [];
    for (const m of ["18S", "COI"]) for (const s of ["W1", "W2"]) {
      const r = t(m, s);
      table1Rows.push([`${m} — ${s === "W1" ? C : D}`, int(r.raw), int(r.primers_found), int(r.filtered), int(r.merged),
        int(r.nonchim), int(r.curated), num(r.pct_retained)]);
    }
    const table2Rows = [];
    for (const m of ["18S", "COI"]) for (const s of ["W1", "W2"]) {
      const r = alpha[`${m}_${s}`];
      table2Rows.push([`${m} — ${s === "W1" ? C : D}`, int(r.rarefied_to), int(r.observed), num(r.shannon, 2), num(r.simpson, 3),
        num(r.pielou, 2), num(r.hill_q1), num(r.hill_q2)]);
    }
    const table3Rows = ind.map((r) => [`*${r.name.replace(/_/g, " ")}*`, r.marker, r.group, num(r.chandubi_pct, 2), num(r.deepor_pct, 2)]);
    
    // ---------------------------------------------------------------- references (APA 7)
    function apaAuthors(list) {
      const f = (x) => {
        const init = (x.given || "").split(/[\s.\-]+/).filter(Boolean).map((p) => p[0].toUpperCase() + ".").join(" ");
        return init ? `${x.family}, ${init}` : x.family;
      };
      const n = list.map(f);
      if (n.length === 1) return n[0];
      if (n.length <= 20) return n.slice(0, -1).join(", ") + ", & " + n[n.length - 1];
      return n.slice(0, 19).join(", ") + ", … " + n[n.length - 1];
    }
    const sentence = (s) => s.replace(/\b([A-Z])([a-z]+)\b/g, (w, a1, rest, off) => (off === 0 ? w : w.toLowerCase()))
      .replace(/\b(dna|edna|rrna|pcr|coi|its|illumina|dada2|pr2|vsearch|diamond|seqkit|multiqc|sintax|ramsar|indian|india|assam|north|east|deepor|beel|arctic|ocean|dianchi|lake|singal|reservoir|cutadapt|fasta\/q|cytochrome|co1)\b/gi,
        (w) => ({ dna: "DNA", edna: "eDNA", rrna: "rRNA", pcr: "PCR", coi: "COI", its: "ITS", illumina: "Illumina",
          dada2: "DADA2", pr2: "PR2", vsearch: "VSEARCH", diamond: "DIAMOND", seqkit: "SeqKit", multiqc: "MultiQC",
          sintax: "SINTAX", ramsar: "Ramsar", indian: "Indian", india: "India", assam: "Assam", north: "North", east: "East",
          deepor: "Deepor", beel: "Beel", arctic: "Arctic", ocean: "Ocean", dianchi: "Dianchi", lake: "Lake",
          singal: "Singal", reservoir: "Reservoir", cutadapt: "Cutadapt", "fasta/q": "FASTA/Q", cytochrome: "cytochrome",
          co1: "CO1" })[w.toLowerCase()] || w);
    const apaRefs = Object.entries(refs).filter(([k]) => !k.startsWith("Deepor_") || k === "Deepor_algae2021");
    // The regional algae paper is keyed Deepor_algae2021 in step 14; cite it as Baruah2020
    refs.Baruah2020 = refs.Deepor_algae2021;
    const refParas = apaRefs.map(([k, r]) => {
      const parts = [new TextRun({ text: `${apaAuthors(r.authors)} (${r.year}). ${sentence(r.title)}. `, font: FONT, size: SIZE })];
      parts.push(new TextRun({ text: r.journal + (r.volume ? `, ${r.volume}` : ""), italics: true, font: FONT, size: SIZE }));
      parts.push(new TextRun({ text: `${r.issue ? `(${r.issue})` : ""}${r.pages ? `, ${r.pages.replace("-", "–")}` : ""}. https://doi.org/${r.doi}`, font: FONT, size: SIZE }));
      return { key: r.authors[0].family + r.year, p: new Paragraph({ children: parts, indent: { left: 360, hanging: 360 }, spacing: { after: 100, line: 300 } }) };
    });
    refParas.push({ key: "Oksanen2025", p: new Paragraph({ children: [
      new TextRun({ text: "Oksanen, J., Simpson, G. L., Blanchet, F. G., Kindt, R., Legendre, P., Minchin, P. R., et al. (2025). ", font: FONT, size: SIZE }),
      new TextRun({ text: "vegan: Community ecology package", italics: true, font: FONT, size: SIZE }),
      new TextRun({ text: " (R package version 2.7-5). https://CRAN.R-project.org/package=vegan", font: FONT, size: SIZE })],
      indent: { left: 360, hanging: 360 }, spacing: { after: 100, line: 300 } }) });
    refParas.push({ key: "R Core Team2026", p: new Paragraph({ children: [
      new TextRun({ text: "R Core Team. (2026). ", font: FONT, size: SIZE }),
      new TextRun({ text: "R: A language and environment for statistical computing", italics: true, font: FONT, size: SIZE }),
      new TextRun({ text: " (Version 4.5.3). R Foundation for Statistical Computing. https://www.R-project.org/", font: FONT, size: SIZE })],
      indent: { left: 360, hanging: 360 }, spacing: { after: 100, line: 300 } }) });
    refParas.sort((x, y) => x.key.localeCompare(y.key, "en", { sensitivity: "base" }));
    
    // ---------------------------------------------------------------- assemble
    const body = [];
    body.push(new Paragraph({ children: [new TextRun({ text: TITLE, bold: true, font: FONT, size: 28 })], spacing: { after: 240 } }));
    body.push(P("Sahil Seikh¹  [add co-authors]", { alignment: AlignmentType.LEFT }));
    body.push(P("¹ Department of Zoology, B. Borooah College (Autonomous), Guwahati, Assam, India", { alignment: AlignmentType.LEFT }));
    body.push(P("Corresponding author: Sahil Seikh, sahilseikh132@gmail.com (ORCID: [add])", { alignment: AlignmentType.LEFT }));
    body.push(H1("Abstract"), P(abstract));
    body.push(P(`**Keywords** ${keywords}`, { alignment: AlignmentType.LEFT }));
    body.push(H1("Introduction"), ...intro.map((x) => P(x)));
    body.push(H1("Materials and methods"));
    for (const [h, txt] of methods) body.push(H2(h), P(txt));
    body.push(H1("Results and discussion"));
    const FIGS = {
      "Sequencing depth and data processing": [
        () => TABLE(1, `Sequencing and processing summary: read pairs remaining after each step and the share of raw pairs retained`,
          ["Marker — wetland", "Raw", "Primers", "Filtered", "Merged", "Non-chimeric", "Curated", "% retained"],
          table1Rows, [2300, 900, 900, 900, 900, 1100, 900, 1000]),
        () => FIG(1, "Read processing and sequencing depth. **a** Read pairs remaining after each processing step for each marker and wetland. **b** Rarefaction curves of curated amplicon sequence variants (ASVs); curves reaching a plateau indicate sufficient sequencing depth")],
      "Taxonomic resolution and the reference-library gap": [
        () => FIG(2, "Identity of the unassigned COI ASVs to the closest known sequence at DNA level (open circles; GenBank, BLASTn) and protein level (filled circles; translated CO1 Reference Set, DIAMOND). Labels give the consensus phylum/class of the closest proteins; 'mixed' indicates no agreement")],
      "Community composition and ecological roles": [
        () => FIG(3, "Community composition. **a** Share of 18S rRNA reads by ecological role. **b** Share of COI reads by animal group, combining reference-set and protein-level assignments"),
        () => FIG(4, "The 30 most abundant named genera from both markers, as percentage of all reads of the respective marker in each wetland, ordered from Chandubi-leaning (top) to Deepor-leaning (bottom)")],
      "Diversity and overlap between wetlands": [
        () => TABLE(2, "Alpha diversity after rarefaction to equal depth per marker (mean of 100 subsamples)",
          ["Marker — wetland", "Depth", "ASVs", "Shannon", "Simpson", "Pielou", "Hill q1", "Hill q2"],
          table2Rows, [2300, 1000, 900, 900, 1000, 900, 1000, 1000]),
        () => FIG(5, "Diversity and overlap. **a** Alpha diversity as effective numbers of ASVs (richness, Hill q1, Hill q2). **b** Share of ASVs, named genera and named species found only in Chandubi Beel, in both wetlands, or only in Deepor Beel; numbers are counts")],
      "Biological indicators of trophic state": [
        () => TABLE(3, "Indicator taxa and their share of reads (%) of the respective marker in each wetland",
          ["Taxon", "Marker", "Group", "Chandubi", "Deepor"], table3Rows, [3100, 900, 1700, 1100, 1100],
          "Indicator associations follow the general water-quality literature; percentages are relative read abundance"),
        () => FIG(6, "Rotifer genera represented by species-level units (COI ASVs clustered at 97% identity) in each wetland. The Brachionus:Trichocerca quotient (QB/T) is given in the text")],
      "Agreement with microscopy": [
        () => FIG(7, "Taxa reported by microscopy in Deepor Beel (rotifers: Sharma, 2010; algae: Baruah et al., 2020) and whether they were also detected by eDNA")],
    };
    for (const [h, paras] of results) {
      body.push(H2(h), ...paras.map((x) => P(x)));
      for (const f of FIGS[h] || []) body.push(...f());
    }
    body.push(H1("Conclusions"), P(conclusions));
    for (const [h, txt] of declarations) body.push(H1(h), P(txt));
    body.push(H1("References"), ...refParas.map((r) => r.p));
    
    const doc = new Document({
      creator: "Sahil Seikh",
      title: TITLE,
      styles: { default: { document: { run: { font: FONT, size: SIZE } } },
                paragraphStyles: [
                  { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
                    run: { bold: true, size: 22, font: FONT }, paragraph: { outlineLevel: 0 } },
                  { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
                    run: { size: SIZE, font: FONT }, paragraph: { outlineLevel: 1 } }] },
      sections: [{
        properties: {
          page: { size: { width: 11906, height: 16838 }, margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 } },
          lineNumbers: { countBy: 1, restart: LineNumberRestartFormat.CONTINUOUS },
        },
        footers: { default: new Footer({ children: [new Paragraph({ alignment: AlignmentType.CENTER,
          children: [new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: SIZE })] })] }) },
        children: body,
      }],
    });
    
    const out = R("results/12_manuscript/Manuscript_EMAS.docx");
    Packer.toBuffer(doc).then((b) => { fs.writeFileSync(out, b); console.log(`wrote ${out} (figures ${figs.join(", ")})`); });
    

    View on GitHub

    Script scripts/13_sra_prep.py
    #!/usr/bin/env python3
    """Step 13 — Prepare an NCBI SRA submission: one BioSample per lake (MIMARKS survey, water
    package) and one SRA run per marker x lake (4 runs, 8 FASTQ files). Raw files share names
    across markers (W1_R1.fastq.gz ...), which SRA does not allow in one submission, so uniquely
    named hard links are created in work/sra_upload/ for uploading.
    Sampling date, coordinates and the registered BioProject come from the authors."""
    import csv
    import hashlib
    import os
    from pathlib import Path
    
    ROOT = Path(__file__).resolve().parent.parent
    OUT = ROOT / "results/13_sra"
    UPLOAD = ROOT / "work/sra_upload"
    BIOPROJECT = "PRJNA1551452"
    COLLECTION_DATE = "2026-03-29"
    OUT.mkdir(parents=True, exist_ok=True)
    UPLOAD.mkdir(parents=True, exist_ok=True)
    
    sites = {
        "W1": {"folder": "Chandubi", "name": "Chandubi_Beel", "title": "Chandubi Beel lake water, eDNA",
               "geo": "India: Assam, Kamrup, Chandubi Beel", "lat_lon": "25.881015 N 91.422898 E"},
        "W2": {"folder": "Dipor Beel", "name": "Deepor_Beel", "title": "Deepor Beel (Ramsar site) lake water, eDNA",
               "geo": "India: Assam, Guwahati, Deepor Beel", "lat_lon": "26.10953 N 91.70818 E"},
    }
    markers = {
        "18S": {"desc": "18S rRNA V4 amplicon, primers E572F (CYGCGGTAATTCCAGCTC) / E1009R "
                        "(AYGGTATCTRWTCRTCTTYG); Illumina MiSeq 2x300 bp",
                "title": "18S rRNA V4 amplicon of lake water eDNA"},
        "COI": {"desc": "COI Leray fragment amplicon, primers mlCOIintF (GGWACWGGWTGAACWGTWTAYCCYCC) / "
                        "jgHCO2198 (TAIACYTCIGGRTGICCRAARAAYCA); Illumina MiSeq 2x300 bp",
                "title": "COI amplicon of lake water eDNA"},
    }
    
    biosample_cols = ["*sample_name", "sample_title", "bioproject_accession", "*organism", "*collection_date",
                      "*env_broad_scale", "*env_local_scale", "*env_medium", "*geo_loc_name", "*lat_lon", "description"]
    with open(OUT / "biosample_MIMARKS_survey_water.tsv", "w", newline="") as fh:
        w = csv.writer(fh, delimiter="\t")
        w.writerow(biosample_cols)
        for sid, s in sites.items():
            w.writerow([s["name"], s["title"], BIOPROJECT,
                        "freshwater metagenome", COLLECTION_DATE,
                        "freshwater lake biome [ENVO:01000252]", "freshwater lake [ENVO:00000021]",
                        "lake water [ENVO:04000007]", s["geo"], s["lat_lon"],
                        f"Surface water sample {sid}; eDNA metabarcoding with 18S rRNA V4 and COI markers"])
    
    sra_cols = ["sample_name", "library_ID", "title", "library_strategy", "library_source", "library_selection",
                "library_layout", "platform", "instrument_model", "design_description", "filetype",
                "filename", "filename2", "filename_md5", "filename2_md5"]
    
    
    def md5(p):
        h = hashlib.md5()
        with open(p, "rb") as f:
            for chunk in iter(lambda: f.read(1 << 20), b""):
                h.update(chunk)
        return h.hexdigest()
    
    
    with open(OUT / "sra_metadata.tsv", "w", newline="") as fh:
        w = csv.writer(fh, delimiter="\t")
        w.writerow(sra_cols)
        for m, mk in markers.items():
            for sid, s in sites.items():
                names = []
                for r in ("R1", "R2"):
                    src = ROOT / "NEW" / m / s["folder"] / f"{sid}_{r}.fastq.gz"
                    dst = UPLOAD / f"{m}_{s['name']}_{sid}_{r}.fastq.gz"
                    if dst.exists():
                        dst.unlink()
                    os.link(src, dst)
                    names.append((dst.name, md5(dst)))
                w.writerow([s["name"], f"{m}_{s['name']}", f"{mk['title']}: {s['title'].split(',')[0]}",
                            "AMPLICON", "METAGENOMIC", "PCR", "paired", "ILLUMINA", "Illumina MiSeq",
                            mk["desc"], "fastq", names[0][0], names[1][0], names[0][1], names[1][1]])
    print(f"wrote {OUT}/biosample_MIMARKS_survey_water.tsv, {OUT}/sra_metadata.tsv; files in {UPLOAD}")
    

    View on GitHub

  14. Step 14

    Checking every reference

    In plain wordsA reference list is only as good as its weakest entry. Every work cited in the draft and in the Methods was looked up in Crossref, the registry behind DOIs, and its title, first author and year compared with what we cite. The final list is built from those records, not typed by hand.

    24 of 26references confirmed
    2could not be confirmed
    1new regional source found
    • One cited title was incomplete (Leray et al. 2013 — full title restored); the Deepor algae paper is Baruah et al. 2020, not 2021.
    • Two regional papers (Tropical Ecology 2011, Indian Journal of Fisheries 2015) are not registered in Crossref and could not be confirmed electronically — check them against the printed journals before citing.
    • While checking, a directly relevant microscopy survey turned up: Sharma (2010), Rotifer communities of Deepor Beel — used in the next step.
    For specialists — scripts, parameters & full tables
    Script scripts/14_verify_references.py
    #!/usr/bin/env python3
    """Step 14 — Verify every reference cited in the draft manuscript and the Methods against
    Crossref. For references with a DOI: fetch the record and compare title, first author and year
    with what we cite. For references without a DOI: search Crossref by title and report the best
    match. Writes results/14_references/references_verified.tsv and a formatted reference list built
    from the Crossref records (not from our own notes)."""
    import csv
    import difflib
    import json
    import re
    import time
    import urllib.parse
    import urllib.request
    from pathlib import Path
    
    ROOT = Path(__file__).resolve().parent.parent
    OUT = ROOT / "results/14_references"
    OUT.mkdir(parents=True, exist_ok=True)
    UA = {"User-Agent": "Sahil_Metagenome/1.0 (reference check; https://github.com/Angsumi/Sahil_Metagenome)"}
    
    # (key, cited first author, cited year, cited title, DOI or "")
    REFS = [
        ("Buchfink2021", "Buchfink", 2021, "Sensitive protein alignments at tree-of-life scale using DIAMOND", "10.1038/s41592-021-01101-x"),
        ("Callahan2016", "Callahan", 2016, "DADA2: High-resolution sample inference from Illumina amplicon data", "10.1038/nmeth.3869"),
        ("Chen2025", "Chen", 2025, "Advancing aquatic biodiversity assessments of invertebrates using eDNA metabarcoding: a systematic evaluation of primers for marine and freshwater communities", "10.1111/2041-210X.70152"),
        ("Comeau2011", "Comeau", 2011, "Arctic Ocean microbial community structure before and after the 2007 record sea ice minimum", "10.1371/journal.pone.0027492"),
        ("Edgar2016", "Edgar", 2016, "SINTAX: a simple non-Bayesian taxonomy classifier for 16S and ITS sequences", "10.1101/074161"),
        ("Ewels2016", "Ewels", 2016, "MultiQC: summarize analysis results for multiple tools and samples in a single report", "10.1093/bioinformatics/btw354"),
        ("Geller2013", "Geller", 2013, "Redesign of PCR primers for mitochondrial cytochrome c oxidase subunit I for marine invertebrates and application in all-taxa biotic surveys", "10.1111/1755-0998.12138"),
        ("Guillou2013", "Guillou", 2013, "The Protist Ribosomal Reference database (PR2): a catalog of unicellular eukaryote Small Sub-Unit rRNA sequences with curated taxonomy", "10.1093/nar/gks1160"),
        ("Jo2026", "Jo", 2026, "Seasonal and spatiotemporal dynamics of plankton communities using eDNA metabarcoding in Singal Reservoir", "10.1007/s00267-025-02357-1"),
        ("Lemonnier2025", "Lemonnier", 2025, "Comparison of metabarcoding and shotgun sequencing confirms the relevance of chloroplastic rRNA genes to assess community structure of lake phytoplankton", "10.1111/1755-0998.70077"),
        ("Leray2013", "Leray", 2013, "A new versatile primer set targeting a short fragment of the mitochondrial COI region for metabarcoding metazoan diversity", "10.1186/1742-9994-10-34"),
        ("Lin2023", "Lin", 2023, "Environmental DNA metabarcoding revealed the impacts of anthropogenic activities on phytoplankton diversity in Dianchi Lake and its three inflow rivers", "10.1002/ece3.10088"),
        ("Martin2011", "Martin", 2011, "Cutadapt removes adapter sequences from high-throughput sequencing reads", "10.14806/ej.17.1.200"),
        ("Porter2018", "Porter", 2018, "Automated high throughput animal CO1 metabarcode classification", "10.1038/s41598-018-22505-4"),
        ("Rognes2016", "Rognes", 2016, "VSEARCH: a versatile open source tool for metagenomics", "10.7717/peerj.2584"),
        ("Sahu2025", "Sahu", 2025, "Environmental DNA (eDNA): an eco-friendly approach for conservation of Indian freshwater diversity—a review", "10.1007/s11356-025-36202-z"),
        ("Shen2016", "Shen", 2016, "SeqKit: a cross-platform and ultrafast toolkit for FASTA/Q file manipulation", "10.1371/journal.pone.0163962"),
        ("Sildever2021", "Sildever", 2021, "Plankton biodiversity and species co-occurrence based on environmental DNA – a multiple marker study", "10.3897/mbmg.5.72371"),
        ("Sladecek1983", "Sládeček", 1983, "Rotifers as indicators of water quality", "10.1007/BF00027429"),
        ("Stefanni2018", "Stefanni", 2018, "Multi-marker metabarcoding approach to study mesozooplankton at basin scale", "10.1038/s41598-018-30157-7"),
        ("Zhang2018", "Zhang", 2018, "Metabarcoding using multiplexed markers increases species detection in complex zooplankton communities", "10.1111/eva.12694"),
        ("Zhao2021", "Zhao", 2021, "DNA metabarcoding of zooplankton communities: species diversity and seasonal variation revealed by 18S rRNA and COI", "10.7717/peerj.11057"),
        # Regional, morphology-based studies (no DOI known): searched by title
        ("Deepor_zooplankton2011", "", 2011, "Zooplankton communities of Deepor Beel (a Ramsar site), Assam (N.E. India): ecology, richness and abundance", ""),
        ("Deepor_phytoplankton2015", "", 2015, "Phytoplankton diversity of Deepor beel, a Ramsar site and floodplain lake of the Brahmaputra basin, Assam", ""),
        ("Deepor_algae2021", "", 2021, "Algal diversity in Deepor Beel of Assam: a Ramsar site of North East India", ""),
        # Found while checking the regional references: microscopy survey of Deepor Beel rotifers
        ("Sharma2010", "Sharma", 2010, "Rotifer communities of Deepor Beel, Assam, India: richness, abundance and ecology", "10.11609/jott.o2411.1077-86"),
    ]
    
    
    def get(url):
        for attempt in range(3):
            try:
                with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60) as r:
                    return json.load(r)
            except urllib.error.HTTPError as e:
                if e.code == 404:
                    return None
                time.sleep(3)
            except Exception:
                time.sleep(3)
        return None
    
    
    def norm(t):
        t = re.sub(r"<[^>]+>", "", t or "")
        t = re.sub(r"[^a-z0-9 ]", " ", t.lower())
        return re.sub(r"\s+", " ", t).strip()
    
    
    def fmt(m, cited_title="", cited_year=None):
        authors = m.get("author", [])
        names = [f"{a.get('family', '')} {''.join(p[0] for p in re.split(r'[ -]', a.get('given', '')) if p)}".strip()
                 for a in authors]
        au = ", ".join(names[:6]) + (" et al." if len(names) > 6 else "")
        year = (m.get("issued", {}).get("date-parts") or [[None]])[0][0]
        print_year = (m.get("published-print", {}).get("date-parts") or [[None]])[0][0]
        if cited_year and cited_year in (year, print_year):
            year = cited_year
        title = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", (m.get("title") or [""])[0])).strip().strip('"')
        if cited_title and norm(cited_title).startswith(norm(title)) and len(cited_title) > len(title):
            title = cited_title  # Crossref holds a truncated title
        jour = (m.get("container-title") or [m.get("publisher", "")])[0]
        vol = m.get("volume", "")
        pages = m.get("page", "") or m.get("article-number", "")
        loc = f" {vol}" if vol else ""
        loc += f": {pages}" if pages else ""
        return f"{au} ({year}). {title}. {jour}{loc}. https://doi.org/{m['DOI']}"
    
    
    rows, formatted, records = [], [], {}
    for key, author, year, title, doi in REFS:
        if doi:
            msg = (get("https://api.crossref.org/works/" + urllib.parse.quote(doi)) or {}).get("message")
            if not msg:
                rows.append([key, doi, "DOI NOT FOUND", "", "", "", "", ""])
                continue
        else:
            q = urllib.parse.urlencode({"query.bibliographic": title, "rows": 1})
            items = ((get("https://api.crossref.org/works?" + q) or {}).get("message") or {}).get("items") or []
            msg = items[0] if items else None
            if not msg:
                rows.append([key, "", "NO MATCH IN CROSSREF", "", "", "", "", ""])
                continue
        ctitle = re.sub(r"<[^>]+>", "", (msg.get("title") or [""])[0])
        cyear = (msg.get("issued", {}).get("date-parts") or [[None]])[0][0]
        cfirst = (msg.get("author") or [{}])[0].get("family", "")
        sim = difflib.SequenceMatcher(None, norm(title), norm(ctitle)).ratio()
        years = {(msg.get(k, {}).get("date-parts") or [[None]])[0][0] for k in ("issued", "published-print", "published-online")}
        nt, nc = norm(title).strip(), norm(ctitle).strip()
        issues, notes = [], []
        if sim < 0.9:
            if doi and nc and nt.startswith(nc):
                notes.append("Crossref stores a truncated title")
            elif doi and nt and nc.startswith(nt):
                notes.append("cited title is incomplete — use the full Crossref title")
            else:
                issues.append(f"title differs (similarity {sim:.2f})")
        if year not in years and any(y and abs(y - year) == 1 for y in years):
            notes.append(f"Crossref dates it {cyear}; online and print issue fall in different years")
        elif year not in years:
            issues.append(f"year {cyear} not {year}")
        elif cyear != year:
            notes.append(f"online {min(y for y in years if y)}, print {year}")
        if author and norm(cfirst) != norm(author):
            issues.append(f"first author {cfirst} not {author}")
        if doi:
            status = ("VERIFIED" + (f" ({'; '.join(notes)})" if notes else "")) if not issues else "CHECK: " + "; ".join(issues)
        else:
            status = (f"FOUND (no DOI given; year {cyear}): " if sim >= 0.9 else "NOT FOUND — best Crossref match is a different work: ") + \
                     f"similarity {sim:.2f}"
        rows.append([key, msg.get("DOI", ""), status, cfirst, cyear, ctitle,
                     (msg.get("container-title") or [""])[0], f"{sim:.2f}"])
        if status.startswith(("VERIFIED", "FOUND")):
            formatted.append((key, fmt(msg, title, year)))
            ctit = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", (msg.get("title") or [""])[0])).strip().strip('"')
            if norm(title).startswith(norm(ctit)) and len(title) > len(ctit):
                ctit = title
            pyear = (msg.get("published-print", {}).get("date-parts") or [[None]])[0][0]
            records[key] = {"authors": [{"family": a.get("family", a.get("name", "")), "given": a.get("given", "")}
                                        for a in msg.get("author", [])],
                            "year": year if year in (cyear, pyear) else cyear, "title": ctit,
                            "journal": (msg.get("container-title") or [msg.get("publisher", "")])[0],
                            "volume": msg.get("volume", ""), "issue": msg.get("issue", ""),
                            "pages": msg.get("page", "") or msg.get("article-number", ""), "doi": msg.get("DOI", "")}
        time.sleep(0.3)
    
    json.dump(records, open(OUT / "references_records.json", "w"), indent=1, ensure_ascii=False)
    with open(OUT / "references_verified.tsv", "w", newline="") as fh:
        w = csv.writer(fh, delimiter="\t")
        w.writerow(["key", "doi", "status", "crossref_first_author", "crossref_year", "crossref_title",
                    "crossref_journal", "title_similarity"])
        w.writerows(rows)
    
    with open(OUT / "references_formatted.md", "w") as fh:
        fh.write("# References (verified against Crossref)\n\n")
        fh.write("Built from Crossref records. Software without a paper (vegan) is cited as an R package.\n\n")
        for i, (_, f) in enumerate(sorted(formatted, key=lambda x: x[1].lower()), 1):
            fh.write(f"{i}. {f}\n")
        fh.write("\nOksanen J, Simpson GL, Blanchet FG, et al. vegan: Community Ecology Package. "
                 "R package version 2.7-5. https://CRAN.R-project.org/package=vegan\n")
        unresolved = [r for r in rows if not r[2].startswith(("VERIFIED", "FOUND"))]
        if unresolved:
            fh.write("\n## Not verified — do not cite until checked against the original\n\n")
            for r in unresolved:
                fh.write(f"- {r[0]}: {r[2]}\n")
    for r in rows:
        print(f"{r[0]:<26} {r[2]}")
    

    View on GitHub

    Output files

  15. Step 15

    Does DNA agree with the microscope?

    In plain wordsBefore DNA methods, biologists counted plankton under the microscope. Two such surveys exist for Deepor Beel: rotifers in 2002–03 and algae published in 2020. If DNA is reliable, it should find much of what they found — and may find more.

    Algae — strong agreement on what matters. The microscopists found desmids, above all Cosmarium, to be the richest group in Deepor; DNA independently picks out the same desmids (Staurastrum, Cosmarium, Micrasterias, Closterium, Onychonema…). In all, 16 algal genera are shared, and DNA adds 23 genera the survey did not list. Of the genera seen only under the microscope, 17 are cyanobacteria or euglenoids, which this DNA marker cannot see or sees poorly — cyanobacteria are bacteria and have no 18S gene at all.

    Rotifers — agreement at the genus level. Of the five rotifer genera the microscopists found most important, DNA detects four (Brachionus, Keratella, Asplanchna and, provisionally, Lecane). At species level only Keratella tropica and Polyarthra vulgaris are confirmed, because most rotifer species have no DNA in the reference libraries yet — the same gap seen in Steps 7 and 10.

    Taxa recorded by microscopy in Deepor Beel, and how many of them the DNA data also detected.
    Taxa recorded by microscopy in Deepor Beel, and how many of them the DNA data also detected.

    The surveys were made in other years and seasons; only presence is compared. Historical water quality from the 2002–03 survey (pH ≈ 6.9, dissolved oxygen ≈ 7 mg/l, conductivity ≈ 98 µS/cm) is context only, not a measurement of these samples.

    For specialists — scripts, parameters & full tables

    Literature taxa in scripts/literature_deepor.tsv: rotifer families, genera and species from Sharma (2010, Table 3); algal genera from the species accounts of Baruah et al. (2020). DNA detections in Deepor (W2) only.

    Script scripts/15_microscopy_comparison.R
    # Step 15 — Compare the DNA results for Deepor Beel (W2) with published microscopy surveys of the
    # same lake (scripts/literature_deepor.tsv): rotifers from Sharma (2010) and algae from
    # Baruah et al. (2020). Surveys differ in year, season and method, so this tests agreement of the
    # species/genus lists, not abundances.
    # Evidence levels for DNA detections: "named" = reference-library name (step 6); "provisional" =
    # genus of the best GenBank DNA hit >= 80 % (step 7) or best protein hit >= 89 % with rotifer
    # consensus (step 10).
    suppressPackageStartupMessages({ library(data.table); library(ggplot2) })
    
    out <- "results/15_microscopy"
    dir.create(out, recursive = TRUE, showWarnings = FALSE)
    rd <- function(p) fread(p, sep = "\t", quote = "", na.strings = c("", "NA"))
    lit <- rd("scripts/literature_deepor.tsv")
    
    # ---- Rotifers detected in Deepor by DNA ----
    coi <- rd("results/09_ecology/COI_asv_groups.tsv")
    t18 <- rd("results/06_taxonomy/18S_taxonomy.tsv")
    bl <- rd("results/06_taxonomy/COI_blast_unknowns_summary.tsv")
    pr <- rd("results/10_protein/COI_protein_summary.tsv")
    w2 <- coi[W2 > 0]
    named_sp <- unique(gsub("_", " ", na.omit(w2[group == "Rotifers", Species])))
    named_gen <- unique(c(na.omit(w2[group == "Rotifers", Genus]),
                          na.omit(t18[Class %in% "Rotifera" & W2 > 0, Genus])))
    first_word <- function(x) sub(" .*", "", x)
    prov_gen <- unique(c(
      first_word(bl[asv %in% w2$asv & ncbi_identity >= 80 & ncbi_phylum == "Rotifera" &
                      !grepl("environmental|sample", ncbi_best_hit), ncbi_best_hit]),
      first_word(pr[asv %in% w2$asv & aa_identity >= 89 & consensus_phylum %in% "Rotifera", best_hit])))
    family_of <- c(Brachionus = "Brachionidae", Keratella = "Brachionidae", Plationus = "Brachionidae",
                   Platyias = "Brachionidae", Anuraeopsis = "Brachionidae", Lecane = "Lecanidae",
                   Asplanchna = "Asplanchnidae", Polyarthra = "Synchaetidae", Filinia = "Trochosphaeridae",
                   Trichocerca = "Trichocercidae", Lepadella = "Lepadellidae", Colurella = "Lepadellidae",
                   Euchlanis = "Euchlanidae", Mytilina = "Mytilinidae", Epiphanes = "Epiphanidae",
                   Cephalodella = "Notommatidae", Proales = "Proalidae", Hexarthra = "Hexarthridae",
                   Microcodon = "Microcodonidae")
    dna_level <- function(level, name) {
      if (level == "species") return(if (name %in% named_sp) "named" else "not detected")
      gens <- if (level == "genus") name else names(family_of)[family_of == name]
      if (any(gens %in% named_gen)) "named" else if (any(gens %in% prov_gen)) "provisional" else "not detected"
    }
    rot <- lit[group == "Rotifera"]
    rot[, dna_detection := mapply(dna_level, level, name)]
    fwrite(rot[, .(level, name, note, dna_detection)], file.path(out, "rotifers_microscopy_vs_DNA.tsv"), sep = "\t")
    
    dna_rot_gen <- sort(unique(c(named_gen, prov_gen)))
    fwrite(data.table(genus = dna_rot_gen,
                      evidence = fifelse(dna_rot_gen %in% named_gen, "named", "provisional"),
                      in_microscopy_list = dna_rot_gen %in% rot[level == "genus", name]),
           file.path(out, "rotifer_genera_DNA_Deepor.tsv"), sep = "\t")
    
    # ---- Algae: genera in Deepor by DNA (18S) vs Baruah et al. 2020 ----
    roles <- rd("results/09_ecology/18S_asv_roles.tsv")
    algal_roles <- c("Green algae", "Desmids & conjugating algae", "Diatoms", "Golden & other algae", "Dinoflagellates")
    dna_alg <- roles[role %in% algal_roles & W2 > 0 & !is.na(Genus) & !grepl("aceae$|_X", Genus),
                     .(reads = sum(W2)), by = .(Genus, role)]
    mic_alg <- lit[group == "Algae" & level == "genus", name]
    alg <- merge(dna_alg[, .(genus = Genus, role, dna_reads = reads)], data.table(genus = mic_alg, microscopy = TRUE),
                 by = "genus", all = TRUE)
    alg[is.na(microscopy), microscopy := FALSE]
    alg[, found_by := fcase(!is.na(dna_reads) & microscopy, "both", !is.na(dna_reads), "DNA only", default = "microscopy only")]
    # Microscopy-only genera that 18S cannot (cyanobacteria: bacteria, no 18S gene) or rarely
    # (euglenoids: divergent 18S, poorly amplified by V4 primers) detect
    cyano <- c("Aphanocapsa", "Aulosira", "Chroococcus", "Cylindrospermum", "Merismopedia", "Microcystis", "Nostoc",
               "Oscillatoria", "Phormidium", "Pleurocapsa", "Spirulina")
    eugl <- c("Euglena", "Gyropaigne", "Lepocinclis", "Phacus", "Strombomonas", "Trachelomonas")
    alg[, note := fcase(genus %in% cyano, "cyanobacterium: no 18S gene, not detectable",
                        genus %in% eugl, "euglenoid: poorly amplified by 18S V4 primers", default = "")]
    setorder(alg, found_by, -dna_reads, genus)
    fwrite(alg, file.path(out, "algae_genera_microscopy_vs_DNA.tsv"), sep = "\t")
    
    summ <- rbind(
      rot[, .(comparison = paste("Rotifer", level, "(Sharma 2010)"),
              microscopy = .N, also_by_DNA_named = sum(dna_detection == "named"),
              also_by_DNA_provisional = sum(dna_detection == "provisional")), by = level][, -"level"],
      data.table(comparison = "Algal genera (Baruah et al. 2020)", microscopy = length(mic_alg),
                 also_by_DNA_named = alg[found_by == "both", .N], also_by_DNA_provisional = 0L))
    summ[, DNA_only := c(NA, NA, NA, alg[found_by == "DNA only", .N])]
    fwrite(summ, file.path(out, "comparison_summary.tsv"), sep = "\t")
    
    # ---- Figure ----
    pl <- melt(summ, id.vars = "comparison", measure.vars = c("also_by_DNA_named", "also_by_DNA_provisional"),
               variable.name = "evidence", value.name = "n")
    pl[, missed := summ$microscopy[match(comparison, summ$comparison)] -
         ave(n, comparison, FUN = sum)]
    pl <- rbind(pl[, .(comparison, evidence, n)],
                unique(pl[, .(comparison, evidence = "not detected by DNA", n = missed)]))
    pl[, evidence := factor(evidence, levels = c("also_by_DNA_named", "also_by_DNA_provisional", "not detected by DNA"),
                            labels = c("also found by DNA (named)", "also found by DNA (provisional)", "not found by DNA"))]
    pl[, comparison := factor(comparison, levels = rev(summ$comparison))]
    p <- ggplot(pl, aes(n, comparison, fill = evidence)) +
      geom_col(width = 0.6, position = position_stack(reverse = TRUE)) +
      geom_text(aes(label = ifelse(n > 0, n, "")), position = position_stack(vjust = 0.5, reverse = TRUE),
                colour = "white", size = 3.4) +
      scale_fill_manual(values = c("#2b6c8f", "#7fb2cc", "#c9c9c9")) +
      labs(x = "Taxa recorded by microscopy in Deepor Beel", y = NULL, fill = NULL,
           title = "Does DNA find what microscopists found in Deepor Beel?") +
      theme_minimal(base_size = 12) + theme(legend.position = "bottom", plot.title.position = "plot")
    dir.create("work/plots", recursive = TRUE, showWarnings = FALSE)
    saveRDS(p, "work/plots/15_microscopy.rds")
    ggsave("docs/img/15_microscopy.png", p, width = 9.5, height = 4.2, dpi = 120)
    print(rot[, .(level, name, dna_detection)]); print(summ); print(alg[found_by == "both"])
    

    View on GitHub

    comparisonmicroscopyDNA (named)DNA (provisional)DNA only
    Rotifer family (Sharma 2010)733
    Rotifer genus (Sharma 2010)531
    Rotifer species (Sharma 2010)720
    Algal genera (Baruah et al. 2020)6316023

    results/15_microscopy/comparison_summary.tsv

    levelnamenoteDNA
    familyBrachionidaeimportant family (Table 3)named
    familyLecanidaeimportant family (Table 3)provisional
    familyAsplanchnidaeimportant family (Table 3)named
    familySynchaetidaeimportant family (Table 3)named
    familyTrochosphaeridaeimportant family (Table 3)provisional
    familyTrichocercidaeimportant family (Table 3)provisional
    familyLepadellidaeimportant family (Table 3)not detected
    genusLecaneimportant genus (Table 3)provisional
    genusBrachionusimportant genus (Table 3)named
    genusKeratellaimportant genus (Table 3)named
    genusAsplanchnaimportant genus (Table 3)named
    genusPlatyiasimportant genus (Table 3)not detected
    speciesAsplanchna priodontaimportant species (abstract, Table 3)not detected
    speciesKeratella cochlearisimportant species (abstract, Table 3)not detected
    speciesPlatyias quadricornisimportant species (abstract, Table 3)not detected
    speciesLecane leontinaimportant species (abstract, Table 3)not detected
    speciesPolyarthra vulgarisimportant species (abstract, Table 3)named
    speciesKeratella tropicaimportant species (abstract, Table 3)named
    speciesBrachionus falcatusimportant species (abstract, Table 3)not detected

    results/15_microscopy/rotifers_microscopy_vs_DNA.tsv

    Output files

  16. In summary

    What the data tells us

    Two very different microscopic worlds

    Chandubi is an algae-and-grazer lake: green algae, diatoms, protozoa and rotifers. Deepor is richer in kinds but leans towards fungi, water moulds, desmids and small worms. By 18S the lakes share only 14.5% of their DNA types.

    Rotifers rule the zooplankton

    About 60% (Chandubi) and 65% (Deepor) of animal (COI) reads are rotifers, many of them species not yet in any DNA library. Copepods (Mesocyclops, Eucyclops, Microcyclops) are present; water fleas were not detected.

    Two barcodes are better than one

    Most named animal genera were seen by only one of the two barcodes — neither alone gives the full picture.

    An abundant, unknown organism

    The most common COI signature in both lakes matches nothing on record above ~72% at DNA level; even its protein sits between a nematode and stramenopile algae. It is a genuine unknown — a candidate for closer study.

    A nutrient-rich signal in both lakes

    The rotifer index QB/T is about 5 in Chandubi and 5 in Deepor — well above the threshold of 2 for nutrient-rich water (provisional, as some genus names rest on distant matches).

    Shared zooplankton, different microbes

    By COI, 266 of 345 animal signatures occur in both lakes at similar levels, while the algae, protists and fungi (18S) differ sharply. The sequencing lab has confirmed that the samples were handled correctly, so this is treated as a real pattern: the two floodplain lakes appear to draw on the same regional pool of rotifers, while their microscopic producers and decomposers reflect each lake’s own conditions.

    DNA agrees with the microscope

    In Deepor, DNA recovers the desmids that microscopists found dominant and four of the five key rotifer genera — and adds genera the surveys did not list.

    Cautions

    One water sample per lake: differences describe these two samples and cannot be tested statistically. Read counts reflect DNA, not the number or weight of organisms. Traces of fish and mammal DNA in Deepor are too few to interpret.

Words used on this page

eDNA
Environmental DNA — genetic material organisms leave in water, soil or air.
Barcode gene
A short gene that differs between species, used like a product barcode to tell them apart.
Read
One stretch of DNA letters produced by the sequencer.
Primer
A short starter DNA piece used to copy the barcode; removed before analysis.
ASV
Amplicon sequence variant — one exact, unique DNA signature, roughly one kind of organism.
Chimera
An artificial sequence accidentally stitched from two organisms during copying.
Rarefaction
A check of whether sampling more would have found many more signatures.
Reference library
A collection of DNA sequences from organisms that experts have identified, used to name new sequences.
% identity
How many DNA letters two sequences share; 97–100% usually means the same species.
Effective number of types
Diversity expressed as the number of equally common types that would give the same diversity.
Indicator organism
A species whose presence or abundance signals something about water conditions, such as nutrient level.
BLAST / GenBank
BLAST is a search tool; GenBank is the public archive of nearly all published DNA sequences.