diff --git a/paper/appendix_table.tex b/paper/appendix_table.tex index bf61a58..ed2f5de 100644 --- a/paper/appendix_table.tex +++ b/paper/appendix_table.tex @@ -3,14 +3,14 @@ \setlength{\tabcolsep}{3pt} \renewcommand{\arraystretch}{1.15} \begin{longtable}{@{}p{2.1cm} >{\raggedright\arraybackslash}p{1.9cm} >{\raggedright\arraybackslash}p{1.5cm} >{\raggedright\arraybackslash}p{1.3cm} >{\raggedright\arraybackslash}p{1.5cm} >{\raggedright\arraybackslash}p{2.4cm} >{\raggedright\arraybackslash}p{3.0cm}@{}} -\caption{Processing choices of the 103 surveyed \dvv\ studies (the literature survey underpinning this paper). Every study is cited; \texttt{n/r} = not reported in the source. Frequency band, coda window, estimator, reference/stack scheme and uncertainty treatment are the choices Section~\ref{sec:results} shows to control the result.}\label{tab:survey}\\ +\caption{Processing choices of the 103 surveyed publications, one row each (the literature survey underpinning this paper). Every publication is cited; \texttt{n/r} = not reported in the source; \texttt{n/a} in the estimator column marks a publication that does not itself measure \dvv\ (theory, kernels, a companion method) and is kept for the choices it documents. Frequency band, coda window, estimator, reference/stack scheme and uncertainty treatment are the choices Section~\ref{sec:results} shows to control the result.}\label{tab:survey}\\ \toprule -\textbf{Study} & \textbf{Site / region} & \textbf{Freq (Hz)} & \textbf{Coda (s)} & \textbf{Method} & \textbf{Reference / stack} & \textbf{Uncertainty treatment} \\ +\textbf{Publication} & \textbf{Site / region} & \textbf{Freq (Hz)} & \textbf{Coda (s)} & \textbf{Method} & \textbf{Reference / stack} & \textbf{Uncertainty treatment} \\ \midrule \endfirsthead \multicolumn{7}{@{}l}{\footnotesize\itshape Table~\ref{tab:survey} continued}\\ \toprule -\textbf{Study} & \textbf{Site / region} & \textbf{Freq (Hz)} & \textbf{Coda (s)} & \textbf{Method} & \textbf{Reference / stack} & \textbf{Uncertainty treatment} \\ +\textbf{Publication} & \textbf{Site / region} & \textbf{Freq (Hz)} & \textbf{Coda (s)} & \textbf{Method} & \textbf{Reference / stack} & \textbf{Uncertainty treatment} \\ \midrule \endhead \midrule\multicolumn{7}{r@{}}{\footnotesize\itshape continued on next page}\\ @@ -19,7 +19,7 @@ \endlastfoot \citet{Brenguier2008} & Piton de la Fournaise, La Réunion & 0.1-0.9 & n/r (slope of dtau vs tau; short scanning window) & MWCS (doublet) & Daily stacks referenced to long-term stack & linear-slope uncertainty; exclude >0.04\% \\ \hline \citet{Duputel2009} & Piton de la Fournaise, La Réunion & 0.1–1 & n/r & Other & Quasi-real-time stacks vs reference & n/r \\ \hline -\citet{Obermann2013} & Piton de la Fournaise, La Réunion & 0.1-1.0 & 10-45 & stretching & Daily stacks, referenced; coda at increasing lapse times & Weaver et al. coherence-based error formula \\ \hline +\citet{Obermann2013b} & Piton de la Fournaise, La Réunion & 0.1-1.0 & 10-45 & stretching & Daily stacks, referenced; coda at increasing lapse times & Weaver et al. coherence-based error formula \\ \hline \citet{HotovecEllis2014} & Mount St. Helens, WA, USA & 1-10 (also 1-5, 5-10) & windows after S arrival; >=5 with CCC>0.65 & doublet (Snieder 2002) & Stack across several hundred repeating-earthquake families (multiplets) & \textasciitilde{}0.1\% from SD of velocity fit \\ \hline \citet{HotovecEllis2015} & Mount St. Helens, WA, USA & 0.5-10 & 15 s window starting 2 s after first motion & stretching & Combines repeating-earthquake CWI with ambient-noise interferometry & reduced similarity (CC) in stretching window \\ \hline \citet{Rivet2015} & Piton de la Fournaise, La Réunion & 0.25-2 & n/r & MWCS & Daily stacks vs reference; network-averaged dv/v & coherency + linear-regression error \\ \hline diff --git a/paper/build_survey.py b/paper/build_survey.py index 77aacfd..3e2e9e8 100644 --- a/paper/build_survey.py +++ b/paper/build_survey.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 -r"""Generate the paper's 103-study survey bibliography and appendix longtable. +r"""Generate the paper's 103-publication survey bibliography and appendix longtable. Reads the literature survey CSV (``literature/dvv_processing_parameters.csv``) and the cached Crossref metadata (``literature/.crossref_cache.json``) and writes: - - ``paper/survey.bib`` one BibTeX entry per surveyed study, with keys + - ``paper/survey.bib`` one BibTeX entry per surveyed publication, with keys that *reuse* the hand-curated ``references.bib`` - keys when the study is already cited in the + keys when the publication is already cited in the narrative (so every paper appears exactly once in the reference list). - ``paper/appendix_table.tex`` a ``longtable`` cataloguing the processing - choices of every study, with a ``\citet`` to - each — so all 103 studies are *cited*, and the + choices of every publication, with a ``\citet`` to + each — so all 103 publications are *cited*, and the table breaks cleanly across pages (no float overlap with the bibliography). @@ -42,13 +42,35 @@ def doi_of(url: str) -> str | None: # Combining diacritics -> LaTeX accent command (so bibtex, which is byte-based, # can abbreviate accented given names without splitting a multibyte character). _COMBINING = { - "́": "'", "̀": "`", "̈": '"', "̂": "^", "̃": "~", - "̄": "=", "̆": "u", "̇": ".", "̊": "r", "̋": "H", - "̌": "v", "̧": "c", "̨": "k", "̣": "d", + "́": "'", + "̀": "`", + "̈": '"', + "̂": "^", + "̃": "~", + "̄": "=", + "̆": "u", + "̇": ".", + "̊": "r", + "̋": "H", + "̌": "v", + "̧": "c", + "̨": "k", + "̣": "d", +} +_SPECIAL = { + "ø": r"{\o}", + "Ø": r"{\O}", + "ß": r"{\ss}", + "æ": r"{\ae}", + "Æ": r"{\AE}", + "œ": r"{\oe}", + "Œ": r"{\OE}", + "ł": r"{\l}", + "Ł": r"{\L}", + "đ": r"{\dj}", + "ð": r"{\dh}", + "þ": r"{\th}", } -_SPECIAL = {"ø": r"{\o}", "Ø": r"{\O}", "ß": r"{\ss}", "æ": r"{\ae}", - "Æ": r"{\AE}", "œ": r"{\oe}", "Œ": r"{\OE}", "ł": r"{\l}", - "Ł": r"{\L}", "đ": r"{\dj}", "ð": r"{\dh}", "þ": r"{\th}"} def latexify(text: str) -> str: @@ -69,7 +91,9 @@ def latexify(text: str) -> str: base = f"{{\\{acc}{base}}}" out.append(base) else: # unmapped: fall back to ASCII transliteration - out.append(unicodedata.normalize("NFKD", ch).encode("ascii", "ignore").decode()) + out.append( + unicodedata.normalize("NFKD", ch).encode("ascii", "ignore").decode() + ) return "".join(out) @@ -94,20 +118,35 @@ def clean_html(text: str) -> str: Crossref returns magnitude notation as ``Mw``; render the sub/superscripts properly and drop italic/bold tags. """ - text = re.sub(r"(.*?)", - lambda m: r"\textsubscript{" + re.sub(r"<[^>]+>", "", m.group(1)) + "}", - text, flags=re.I | re.S) - text = re.sub(r"(.*?)", - lambda m: r"\textsuperscript{" + re.sub(r"<[^>]+>", "", m.group(1)) + "}", - text, flags=re.I | re.S) + text = re.sub( + r"(.*?)", + lambda m: r"\textsubscript{" + re.sub(r"<[^>]+>", "", m.group(1)) + "}", + text, + flags=re.I | re.S, + ) + text = re.sub( + r"(.*?)", + lambda m: r"\textsuperscript{" + re.sub(r"<[^>]+>", "", m.group(1)) + "}", + text, + flags=re.I | re.S, + ) return re.sub(r"<[^>]+>", "", text) def tex_escape(s: str) -> str: s = str(s or "") - for a, b in [("\\", r"\textbackslash{}"), ("&", r"\&"), ("%", r"\%"), - ("$", r"\$"), ("#", r"\#"), ("_", r"\_"), ("{", r"\{"), - ("}", r"\}"), ("~", r"\textasciitilde{}"), ("^", r"\textasciicircum{}")]: + for a, b in [ + ("\\", r"\textbackslash{}"), + ("&", r"\&"), + ("%", r"\%"), + ("$", r"\$"), + ("#", r"\#"), + ("_", r"\_"), + ("{", r"\{"), + ("}", r"\}"), + ("~", r"\textasciitilde{}"), + ("^", r"\textasciicircum{}"), + ]: s = s.replace(a, b) return s @@ -142,14 +181,70 @@ def existing_keys() -> set[str]: return set(re.findall(r"@\w+\{([^,]+),", REFS.read_text())) +def existing_dois(text: str | None = None) -> dict[str, str | None]: + """``key -> doi`` for every entry of references.bib (``None`` without a doi).""" + if text is None: + if not REFS.exists(): + return {} + text = REFS.read_text() + out: dict[str, str | None] = {} + for m in re.finditer(r"@\w+\{([^,]+),(.*?)(?=\n@|\Z)", text, flags=re.S): + key, body = m.group(1).strip(), m.group(2) + d = re.search(r"doi\s*=\s*[{\"]\s*([^}\"]+?)\s*[}\"]", body, flags=re.I) + out[key] = doi_of(d.group(1)) if d else None + return out + + +def assign_keys( + rows: list[dict], reuse: dict[str, str | None] +) -> list[tuple[str, dict, bool]]: + """Give every survey row a citation key; returns ``(key, row, reused)`` in order. + + A row reuses a ``references.bib`` key only when the surname and year match + *and* the DOIs agree (or the bib entry carries no DOI). Two different + papers with the same surname and year get ``Key2013`` and ``Key2013b`` + (then ``c`` and so on), whichever of them the narrative already cites. + Before the 2026-09 revision a key that existed in references.bib was + reused without the DOI check, so the volcano study of Obermann et al. + (2013, JGR) was cited as the depth-sensitivity paper (GJI) that shares + its key (review finding S-CD.1). + """ + used: dict[str, str] = {} # key -> doi assigned in this run + out: list[tuple[str, dict, bool]] = [] + + def _taken(k: str, doi: str) -> bool: + if k in used: + return used[k] != doi + if k in reuse: + bib_doi = reuse[k] + return bib_doi is not None and bib_doi != doi + return False + + for row in rows: + doi = doi_of(row["doi_url"]) or row["doi_url"] + key = make_key(row["authors_year"], row.get("year", "")) + while _taken(key, doi): + # Key2013 -> Key2013b -> Key2013c: replace a trailing suffix letter + # rather than appending to it. + if key[-1].isdigit(): + key += "b" + else: + key = key[:-1] + chr(ord(key[-1]) + 1) + used[key] = doi + out.append((key, row, key in reuse)) + return out + + def bib_entry(key: str, row: dict, rec: dict | None) -> str: """One BibTeX entry. Falls back to @misc when Crossref metadata is absent.""" doi = doi_of(row["doi_url"]) or "" year = row.get("year", "") if rec and rec.get("title"): + authors = bib_authors(rec) fields = { - "author": bib_authors(rec) and latexify(bib_authors(rec)) - or authors_from_label(row["authors_year"]), + "author": latexify(authors) + if authors + else authors_from_label(row["authors_year"]), "title": latexify(clean_html(rec["title"].rstrip(". "))), "journal": latexify(clean_html(rec.get("container", ""))), "year": rec.get("year") or year, @@ -186,30 +281,50 @@ def bib_entry(key: str, row: dict, rec: dict | None) -> str: def appendix_table(entries: list[tuple[str, dict]]) -> str: - """A longtable cataloguing every study's processing choices (all \\citet).""" - colspec = "@{}p{2.1cm} " + " ".join( - f">{{\\raggedright\\arraybackslash}}p{{{w}}}" for *_, w in COLS) + "@{}" - head = "\\textbf{Study} & " + " & ".join( - f"\\textbf{{{h}}}" for _, h, _ in COLS) + r" \\" + """A longtable cataloguing every publication's processing choices (all \\citet).""" + colspec = ( + "@{}p{2.1cm} " + + " ".join(f">{{\\raggedright\\arraybackslash}}p{{{w}}}" for *_, w in COLS) + + "@{}" + ) + head = ( + "\\textbf{Publication} & " + + " & ".join(f"\\textbf{{{h}}}" for _, h, _ in COLS) + + r" \\" + ) out = [ r"% Auto-generated by paper/build_survey.py -- do not edit by hand.", r"{\footnotesize", r"\setlength{\tabcolsep}{3pt}", r"\renewcommand{\arraystretch}{1.15}", f"\\begin{{longtable}}{{{colspec}}}", - r"\caption{Processing choices of the " + str(len(entries)) + - r" surveyed \dvv\ studies (the literature survey underpinning this paper). " - r"Every study is cited; \texttt{n/r} = not reported in the source. " + r"\caption{Processing choices of the " + + str(len(entries)) + + r" surveyed publications, one row each (the literature survey " + r"underpinning this paper). Every publication is cited; \texttt{n/r} = " + r"not reported in the source; \texttt{n/a} in the estimator column marks " + r"a publication that does not itself measure \dvv\ (theory, kernels, a " + r"companion method) and is kept for the choices it documents. " r"Frequency band, coda window, estimator, reference/stack scheme and " r"uncertainty treatment are the choices Section~\ref{sec:results} shows " r"to control the result.}\label{tab:survey}\\", - r"\toprule", head, r"\midrule", r"\endfirsthead", - r"\multicolumn{" + str(len(COLS) + 1) + - r"}{@{}l}{\footnotesize\itshape Table~\ref{tab:survey} continued}\\", - r"\toprule", head, r"\midrule", r"\endhead", - r"\midrule\multicolumn{" + str(len(COLS) + 1) + - r"}{r@{}}{\footnotesize\itshape continued on next page}\\", r"\endfoot", - r"\bottomrule", r"\endlastfoot", + r"\toprule", + head, + r"\midrule", + r"\endfirsthead", + r"\multicolumn{" + + str(len(COLS) + 1) + + r"}{@{}l}{\footnotesize\itshape Table~\ref{tab:survey} continued}\\", + r"\toprule", + head, + r"\midrule", + r"\endhead", + r"\midrule\multicolumn{" + + str(len(COLS) + 1) + + r"}{r@{}}{\footnotesize\itshape continued on next page}\\", + r"\endfoot", + r"\bottomrule", + r"\endlastfoot", ] for key, row in entries: cells = [f"\\citet{{{key}}}"] @@ -226,21 +341,14 @@ def appendix_table(entries: list[tuple[str, dict]]) -> str: def main() -> None: rows = list(csv.DictReader(CSV.open())) cache = json.loads(CACHE.read_text()) if CACHE.exists() else {} - reuse = existing_keys() + reuse = existing_dois() - entries: list[tuple[str, dict]] = [] # (key, row) in CSV order, for the table - new_bib: list[str] = [] # entries to write to survey.bib - used: dict[str, str] = {} # key -> doi (disambiguation) + entries: list[tuple[str, dict]] = [] # (key, row) in CSV order, for the table + new_bib: list[str] = [] # entries to write to survey.bib - for row in rows: - doi = doi_of(row["doi_url"]) or row["doi_url"] - key = make_key(row["authors_year"], row.get("year", "")) - # Disambiguate identical surname+year that are different papers. - while key in used and used[key] != doi and key not in reuse: - key += "b" if key[-1].isdigit() else chr(ord(key[-1]) + 1) - used[key] = doi + for key, row, reused in assign_keys(rows, reuse): entries.append((key, row)) - if key not in reuse: # reuse references.bib entry if present + if not reused: # reuse references.bib entry if present rec = cache.get(doi_of(row["doi_url"]) or "") new_bib.append(bib_entry(key, row, rec)) @@ -248,11 +356,14 @@ def main() -> None: "% Auto-generated by paper/build_survey.py from the literature survey.\n" "% Keys that also appear in references.bib are intentionally omitted here\n" "% (the narrative entry is reused) so each paper is listed once.\n\n" - + "\n".join(new_bib)) + + "\n".join(new_bib) + ) APPENDIX.write_text(appendix_table(entries)) - reused = len(entries) - len(new_bib) - print(f"wrote {SURVEY_BIB.name} ({len(new_bib)} new entries, {reused} reuse references.bib)") - print(f"wrote {APPENDIX.name} ({len(entries)} studies catalogued + cited)") + n_reused = len(entries) - len(new_bib) + print( + f"wrote {SURVEY_BIB.name} ({len(new_bib)} new entries, {n_reused} reuse references.bib)" + ) + print(f"wrote {APPENDIX.name} ({len(entries)} publications catalogued + cited)") if __name__ == "__main__": diff --git a/paper/data/survey_rates.json b/paper/data/survey_rates.json new file mode 100644 index 0000000..66ba128 --- /dev/null +++ b/paper/data/survey_rates.json @@ -0,0 +1,130 @@ +{ + "source": "literature/dvv_processing_parameters.csv", + "rows": 103, + "distinct_identifiers": 103, + "distinct_dois": 102, + "rows_measuring_dvv": 96, + "rows_not_measuring_dvv": [ + "Köpfli et al., 2024", + "Nakata & Snieder 2011", + "Bertello, Berti, Castellaro, Squarzoni (2018)", + "Tsai, 2011", + "Guillemot et al., 2020", + "Guillemot et al., 2021", + "Snieder, Grêt, Douma & Scales 2002" + ], + "rows_full_text": 82, + "rows_abstract_only": 21, + "rows_full_text_measuring_dvv": 75, + "signal_source": { + "Ambient noise": 81, + "Earthquake coda": 11, + "Both": 11 + }, + "signal_source_measuring_dvv": { + "Ambient noise": 77, + "Earthquake coda": 9, + "Both": 10 + }, + "rows_without_doi": [ + "Kristjánsdóttir et al., 2019" + ], + "earthquake_coda_only_measuring": 9, + "reported_among_full_text_measuring": { + "frequency band": { + "reported": 68, + "of": 75 + }, + "coda window": { + "reported": 59, + "of": 75 + }, + "estimator": { + "reported": 74, + "of": 75 + } + }, + "uncertainty_coding_among_full_text_measuring": { + "error_estimate": 57, + "qc_threshold_only": 11, + "none": 7, + "of": 75 + }, + "uncertainty_codes": { + "Brenguier et al., 2008": "error", + "Obermann et al., 2013": "error", + "Hotovec-Ellis et al., 2014": "error", + "Hotovec-Ellis et al., 2015": "qc", + "Rivet et al., 2015": "error", + "De Plaen et al., 2016": "error", + "Donaldson et al., 2017": "error", + "Takano et al., 2017": "error", + "Lesage et al., 2018": "error", + "De Plaen et al., 2019": "error", + "Donaldson et al., 2019": "qc", + "Olivier et al., 2019": "error", + "Yates et al., 2019": "error", + "Feng et al., 2020": "error", + "Hotovec-Ellis et al., 2022": "none", + "Yates et al., 2024": "error", + "Yukutake et al., 2025": "error", + "Schaff & Beroza 2004": "error", + "Rubinstein & Beroza 2005": "qc", + "Wegler & Sens-Schönfelder 2007": "error", + "Hadziioannou, Larose, Coutant, Roux, Campillo 2009": "error", + "Wegler, Nakahara, Sens-Schönfelder, Korn, Shiomi 2009": "error", + "Chen, Froment, Liu, Campillo 2010": "none", + "Rivet, Campillo, Shapiro, Cruz-Atienza, Radiguet, Cotte, Kostoglodov 2011": "error", + "Hobiger, Wegler, Shiomi, Nakahara 2012": "qc", + "Minato, Tsuji, Ohmi, Matsuoka 2012": "error", + "Obermann, Planès, Larose, Sens-Schönfelder, Campillo 2013": "error", + "Liu, Huang, Peng, Su 2014": "error", + "Taira, Brenguier, Kong 2015": "error", + "Gassenmeier, Sens-Schönfelder, Eulenfeld, Bartsch, Victor, Tilmann, Korn 2016": "error", + "Hillers, Campillo, Brenguier, Moreau, Agnew, Ben-Zion 2019": "qc", + "Wang, Campillo, Brenguier, Lecointre, Takeda, Hashima 2019": "error", + "Mao, Mordret, Campillo, Fang, van der Hilst 2020": "qc", + "Poli, Marguin, Wang, D'Agostino, Johnson 2020": "qc", + "Boschelli, Zaccarelli, Faenza 2021": "error", + "Sheng, Mordret, Sager, Brenguier, Boué, Rousset, Vernon, Higueret, Ben-Zion 2022": "error", + "Mainsant, Larose, Brönnimann, Jongmans, Michoud, Jaboyedoff (2012)": "error", + "Bontemps, Lacroix, Larose, Jara, Taipe (2020)": "none", + "Le Breton, Bontemps, Guillemot, Baillet, Larose (2021)": "error", + "Whiteley, Chambers, Uhlemann et al. (2026)": "none", + "Sens-Schönfelder & Wegler, 2006": "error", + "Lecocq, Longuevergne, Pedersen, Brenguier & Stammler, 2017": "error", + "Nimiya, Ikeda & Tsuji, 2017": "error", + "Wang, Brenguier, Campillo, Lecointre, Takeda & Aoki, 2017": "qc", + "Clements & Denolle, 2018": "qc", + "Kim & Lekic, 2019": "none", + "Andajani, Tsuji, Snieder & Ikeda, 2020": "error", + "Gaubert-Bastide, Garambois, et al., 2022": "error", + "Illien, Andermann, Sens-Schönfelder, et al., 2022": "none", + "Mao, Lecointre, van der Hilst & Campillo, 2022": "error", + "Clements & Denolle, 2023": "error", + "Delouche, et al., 2023": "error", + "Fokker, Ruigrok, Hawkins & Trampert, 2023": "error", + "Zhang et al., 2023": "error", + "Mao, Ellsworth, Zheng & Beroza, 2025": "error", + "Mordret et al., 2016": "error", + "James et al., 2019": "error", + "Lindner et al., 2021": "error", + "Luo et al., 2023": "error", + "Gassenmeier et al., 2015": "error", + "Obermann et al., 2015": "none", + "Olivier et al., 2017": "error", + "Taira et al., 2018": "error", + "Bensen et al. 2007": "error", + "Clarke, Zaccarelli, Shapiro & Brenguier 2011": "error", + "Hadziioannou, Larose, Baig, Roux & Campillo 2011": "qc", + "Weaver, Hadziioannou, Larose & Campillo 2011": "error", + "Zhan, Tsai & Clayton 2013": "error", + "Lecocq, Caudron & Brenguier 2014": "qc", + "Mikesell, Malcolm, Yang & Haney 2015": "error", + "Stehly, Froment, Campillo, Liu & Chen 2015": "error", + "Daskalakis, Evangelidis, Garnier, Melis, Papanicolaou & Tsogka 2016": "error", + "Obermann, Planès, Hadziioannou & Campillo 2016": "error", + "Wang & Yao 2020": "error", + "Yuan, Bryan & Denolle 2021": "error" + } +} diff --git a/paper/manuscript_marine.qmd b/paper/manuscript_marine.qmd index 43fea18..1b1be40 100644 --- a/paper/manuscript_marine.qmd +++ b/paper/manuscript_marine.qmd @@ -160,7 +160,7 @@ without full reporting of parameter choices we cannot interpret a reported \dvv\ as a robust value. Here we make the problem concrete for \dvv\ monitoring. We use purely synthetic correlation time series to test the methods (estimators) and parameter choices that the community -makes to estimate \dvv$\,$which we report over 103 studies in Appendix\ \ref{app:survey}. +makes to estimate \dvv$\,$which we report over 103 publications in Appendix\ \ref{app:survey}. We do not aim to report the "best" pipeline, which is most often the one reported in scientific papers, but instead document the parameter impacts (Section\ \ref{sec:results}). We then propose a new measurement error that incorporates these effects into a data @@ -690,7 +690,7 @@ continuum, from one global reference, through segment references joined pairwise, to the fully coupled inversion, than as a menu of unrelated choices. Against that, the uncumulated day-by-day trailing reference swept above is a -limit case rather than a practice: we found no surveyed study that re-baselines +limit case rather than a practice: we found no surveyed publication that re-baselines continuously and reports the increments without summing them. It is retained here because it isolates what re-baselining costs when the trend is not reconstructed, which is the failure mode the two families above exist to avoid. @@ -791,8 +791,9 @@ RMS an order of magnitude worse even after clearing it. The accepted range for each of these parameters across the published literature is catalogued directly, study by study, in the survey of Appendix\ \ref{app:survey} (Table\ \ref{tab:survey}): the frequency band, coda -window, estimator, and uncertainty treatment actually reported by 103 ambient-noise -\dvv\ studies. +window, estimator, and uncertainty treatment actually reported by the 96 +publications of the survey that measure \dvv\ (of 103 catalogued; 77 from +ambient noise alone, 9 from earthquake coda alone, 10 from both). ## Coda window {#sec:param_window} @@ -808,7 +809,7 @@ $\sim\!3.9\,\%$ (the noise floor, not the signal), while a window hand-adapted to the band (3--12$\,$s) recovers the truth at $\sim\!0.01\,\%$ --- nearly a 400$\times$ difference from this one choice alone. This is itself an instance of a literature-documented tension: band-matched windowing is recommended, but -Table\ \ref{tab:survey} shows many surveyed studies instead reuse one fixed +Table\ \ref{tab:survey} shows many surveyed publications instead reuse one fixed window across bands. Hand-adapting the window per band, as above, requires knowing the band in @@ -1725,27 +1726,57 @@ mean $\bar x$ but obey $\sigma_{\mathrm{SD}}/\sigma_{\mathrm{SE}}=\sqrt{N}$. # Survey of processing choices across the literature {#app:survey} -Table\ \ref{tab:survey} catalogues the processing choices of the ambient-noise -\dvv\ monitoring studies we surveyed: 103 rows for 103 publications, one -per publication (the full machine-readable version and provenance are -distributed with the codameter package). It is the empirical basis -for the paper's claim that no convention is shared: the estimator, the frequency -band, the coda window, the reference scheme and --- most unevenly of all --- the -uncertainty treatment vary study to study, and the last is frequently -unreported. Every study is cited here. +Table\ \ref{tab:survey} catalogues the processing choices of the +coda-wave \dvv\ monitoring publications we surveyed: 103 rows for 103 +publications, one per publication, each identified by its DOI (by its ADS +record for the one conference abstract, @Kristjansdottir2019; the +machine-readable table and its provenance are distributed with the +codameter package). It is +the empirical basis for the paper's claim that no convention is shared: the +estimator, the frequency band, the coda window, the reference scheme and +--- most unevenly of all --- the uncertainty treatment vary from +publication to publication, and the last is frequently unreported. Every +publication is cited here. + +The survey was assembled in June 2026 by six literature scans of the +published journal literature, one per application group (volcano, +earthquake and fault, landslide, groundwater, cryosphere, geothermal and +reservoir, methodology), each recording a DOI per publication; the scans +returned 117 rows, which were deduplicated by DOI, checked against the +Crossref record of each DOI (four wrong DOIs corrected, one unverifiable +row dropped) and reduced to the 103 publications listed (the corrections +are recorded with the machine-readable table). The table keeps a +publication when it measures a temporal relative velocity change from the +coda of ambient-noise correlations or of repeating earthquakes, or when it +defines a method or a sensitivity kernel that the measuring publications +rely on. Seven of the 103 do not themselves measure \dvv\ (theory, +depth kernels, deconvolution interferometry, spectral methods) and are marked +\texttt{n/a} in the estimator column; the counts below exclude them. Of the +96 that measure \dvv, 77 use ambient noise alone, 9 use earthquake coda +alone and 10 both (the machine-readable table records this per row); +the coda-only rows are kept because their processing choices are the same +choices, made on a different wavefield. The four measurement fields (frequency band, coda window, estimator and -uncertainty treatment) were re-checked against the \emph{full text} for the 82 -studies we could read (open access plus institutional access); for those rows a -blank or ``n/r'' means the -value is genuinely not stated in the paper. The remaining rows were populated -from abstracts and search metadata, where ``n/r'' means only that the value was -not found in the abstract, not that the study failed to report it; those cells -are flagged in the machine-readable table (\texttt{measurement\_source}) and -remain to be filled from the paywalled full texts. Because a cell populated from -an abstract can only move from ``n/r'' to reported once the full text is read, -the apparent under-reporting in the table is an upper bound on the true -under-reporting; the verified rate is the one among the 82 full-text rows. +uncertainty treatment) were re-checked against the \emph{full text} for the +82 publications we could read (open access plus institutional access); for +those rows a blank or \texttt{n/r} means the value is genuinely not stated +in the paper. The remaining 21 rows were populated from abstracts and search +metadata, where \texttt{n/r} means only that the value was not found in the +abstract, not that the publication failed to report it; those cells are +flagged in the machine-readable table (\texttt{measurement\_source}), +remain to be filled from the paywalled full texts, and are excluded from +the rates below. Among the 75 full-text publications that measure \dvv, +68 report the frequency band, 59 the coda window and 74 the estimator. For +the uncertainty column we distinguish a stated error on \dvv\ (a formula, +a standard deviation or error, a confidence interval, a regression or +bootstrap error, a stated noise or significance level) from a +quality-control rule alone (a coherence or correlation threshold, a +weighting, a rejection criterion, with no error magnitude) and from +nothing: 57 of the 75 state an error, 11 state a quality rule only, and 7 +state neither. The rule, the keyword coding and the eight cells coded by +hand are recorded with the machine-readable table, together with these +counts. \input{appendix_table.tex} diff --git a/paper/manuscript_marine.tex b/paper/manuscript_marine.tex index 21093a5..6a0cc74 100644 --- a/paper/manuscript_marine.tex +++ b/paper/manuscript_marine.tex @@ -251,7 +251,7 @@ \title{The reproducibility cost of ad-hoc processing choices in ambient-noise seismic velocity-change monitoring} \author{M. A. Denolle} -\date{2026-09-13} +\date{2026-09-14} \begin{document} \maketitle \begin{abstract} @@ -413,16 +413,16 @@ \section{Introduction}\label{sec:intro} Here we make the problem concrete for \dvv~monitoring. We use purely synthetic correlation time series to test the methods (estimators) and parameter choices that the community makes to estimate \dvv\(\,\)which -we report over 103 studies in Appendix~\ref{app:survey}. We do not aim -to report the ``best'' pipeline, which is most often the one reported in -scientific papers, but instead document the parameter impacts -(Section~\ref{sec:results}). We then propose a new measurement error -that incorporates these effects into a data covariance matrix \(C_d\) -(Section~\ref{sec:bayes}). Throughout this paper, key notation includes: -\(\sigma\) (measurement uncertainty of a recovered \dvv~estimate), \(B\) -(frequency bandwidth), and \(W=[t_1,t_2]\) (coda window); subscripts -distinguish specific contexts (e.g., \(\sigma_k\) for per-component -uncertainty), each defined where first introduced. +we report over 103 publications in Appendix~\ref{app:survey}. We do not +aim to report the ``best'' pipeline, which is most often the one +reported in scientific papers, but instead document the parameter +impacts (Section~\ref{sec:results}). We then propose a new measurement +error that incorporates these effects into a data covariance matrix +\(C_d\) (Section~\ref{sec:bayes}). Throughout this paper, key notation +includes: \(\sigma\) (measurement uncertainty of a recovered +\dvv~estimate), \(B\) (frequency bandwidth), and \(W=[t_1,t_2]\) (coda +window); subscripts distinguish specific contexts (e.g., \(\sigma_k\) +for per-component uncertainty), each defined where first introduced. One example of propagating such error into downstream science is the migration of the surface \dvv~measurement to depth profiles of @@ -983,13 +983,13 @@ \subsection{Reference}\label{sec:param_ref} fully coupled inversion, than as a menu of unrelated choices. Against that, the uncumulated day-by-day trailing reference swept above -is a limit case rather than a practice: we found no surveyed study that -re-baselines continuously and reports the increments without summing -them. It is retained here because it isolates what re-baselining costs -when the trend is not reconstructed, which is the failure mode the two -families above exist to avoid. codameter implements the joint inversion -but neither the cumulated trailing reference nor cluster-based reference -selection. +is a limit case rather than a practice: we found no surveyed publication +that re-baselines continuously and reports the increments without +summing them. It is retained here because it isolates what re-baselining +costs when the trend is not reconstructed, which is the failure mode the +two families above exist to avoid. codameter implements the joint +inversion but neither the cumulated trailing reference nor cluster-based +reference selection. \begin{figure} \centering @@ -1094,7 +1094,9 @@ \subsection{Substacking}\label{sec:param_substack} literature is catalogued directly, study by study, in the survey of Appendix~\ref{app:survey} (Table~\ref{tab:survey}): the frequency band, coda window, estimator, and uncertainty treatment actually reported by -103 ambient-noise \dvv~studies. +the 96 publications of the survey that measure \dvv~(of 103 catalogued; +77 from ambient noise alone, 9 from earthquake coda alone, 10 from +both). \subsection{Coda window}\label{sec:param_window} @@ -1112,8 +1114,8 @@ \subsection{Coda window}\label{sec:param_window} recovers the truth at \(\sim\!0.01\,\%\) --- nearly a 400\(\times\) difference from this one choice alone. This is itself an instance of a literature-documented tension: band-matched windowing is recommended, -but Table~\ref{tab:survey} shows many surveyed studies instead reuse one -fixed window across bands. +but Table~\ref{tab:survey} shows many surveyed publications instead +reuse one fixed window across bands. Hand-adapting the window per band, as above, requires knowing the band in advance and re-tuning per deployment. A more principled alternative @@ -2109,27 +2111,55 @@ \section{Survey of processing choices across the literature}\label{app:survey} Table~\ref{tab:survey} catalogues the processing choices of the -ambient-noise \dvv~monitoring studies we surveyed: 103 rows for 103 -publications, one per publication (the full machine-readable version and -provenance are distributed with the codameter package). It is the -empirical basis for the paper's claim that no convention is shared: the -estimator, the frequency band, the coda window, the reference scheme and ---- most unevenly of all --- the uncertainty treatment vary study to -study, and the last is frequently unreported. Every study is cited here. +coda-wave \dvv~monitoring publications we surveyed: 103 rows for 103 +publications, one per publication, each identified by its DOI (by its +ADS record for the one conference abstract, \citet{Kristjansdottir2019}; +the machine-readable table and its provenance are distributed with the +codameter package). It is the empirical basis for the paper's claim that +no convention is shared: the estimator, the frequency band, the coda +window, the reference scheme and --- most unevenly of all --- the +uncertainty treatment vary from publication to publication, and the last +is frequently unreported. Every publication is cited here. + +The survey was assembled in June 2026 by six literature scans of the +published journal literature, one per application group (volcano, +earthquake and fault, landslide, groundwater, cryosphere, geothermal and +reservoir, methodology), each recording a DOI per publication; the scans +returned 117 rows, which were deduplicated by DOI, checked against the +Crossref record of each DOI (four wrong DOIs corrected, one unverifiable +row dropped) and reduced to the 103 publications listed (the corrections +are recorded with the machine-readable table). The table keeps a +publication when it measures a temporal relative velocity change from +the coda of ambient-noise correlations or of repeating earthquakes, or +when it defines a method or a sensitivity kernel that the measuring +publications rely on. Seven of the 103 do not themselves measure +\dvv~(theory, depth kernels, deconvolution interferometry, spectral +methods) and are marked \texttt{n/a} in the estimator column; the counts +below exclude them. Of the 96 that measure \dvv, 77 use ambient noise +alone, 9 use earthquake coda alone and 10 both (the machine-readable +table records this per row); the coda-only rows are kept because their +processing choices are the same choices, made on a different wavefield. The four measurement fields (frequency band, coda window, estimator and uncertainty treatment) were re-checked against the \emph{full text} for -the 82 studies we could read (open access plus institutional access); -for those rows a blank or -\texttt{n/r\textquotesingle{}\textquotesingle{}\ means\ the\ value\ is\ genuinely\ not\ stated\ in\ the\ paper.\ The\ remaining\ rows\ were\ populated\ from\ abstracts\ and\ search\ metadata,\ where}n/r'\,' -means only that the value was not found in the abstract, not that the -study failed to report it; those cells are flagged in the -machine-readable table (\texttt{measurement\_source}) and remain to be -filled from the paywalled full texts. Because a cell populated from an -abstract can only move from ``n/r'\,' to reported once the full text is -read, the apparent under-reporting in the table is an upper bound on the -true under-reporting; the verified rate is the one among the 82 -full-text rows. +the 82 publications we could read (open access plus institutional +access); for those rows a blank or \texttt{n/r} means the value is +genuinely not stated in the paper. The remaining 21 rows were populated +from abstracts and search metadata, where \texttt{n/r} means only that +the value was not found in the abstract, not that the publication failed +to report it; those cells are flagged in the machine-readable table +(\texttt{measurement\_source}), remain to be filled from the paywalled +full texts, and are excluded from the rates below. Among the 75 +full-text publications that measure \dvv, 68 report the frequency band, +59 the coda window and 74 the estimator. For the uncertainty column we +distinguish a stated error on \dvv~(a formula, a standard deviation or +error, a confidence interval, a regression or bootstrap error, a stated +noise or significance level) from a quality-control rule alone (a +coherence or correlation threshold, a weighting, a rejection criterion, +with no error magnitude) and from nothing: 57 of the 75 state an error, +11 state a quality rule only, and 7 state neither. The rule, the keyword +coding and the eight cells coded by hand are recorded with the +machine-readable table, together with these counts. \input{appendix_table.tex} diff --git a/paper/survey.bib b/paper/survey.bib index 77e12b3..d93cd2f 100644 --- a/paper/survey.bib +++ b/paper/survey.bib @@ -22,6 +22,16 @@ @article{Duputel2009 doi = {10.1016/j.jvolgeores.2008.11.024} } +@article{Obermann2013b, + author = {Obermann, A. and Plan{\`e}s, T. and Larose, E. and Campillo, M.}, + title = {Imaging preeruptive and coeruptive structural and mechanical changes of a volcano with ambient seismic noise}, + journal = {Journal of Geophysical Research: Solid Earth}, + year = {2013}, + volume = {118}, + pages = {6285--6294}, + doi = {10.1002/2013jb010399} +} + @article{HotovecEllis2014, author = {HotovecEllis, A. J. and Gomberg, J. and Vidale, J. E. and Creager, K. C.}, title = {A continuous record of intereruption velocity change at Mount St. Helens from coda wave interferometry}, diff --git a/paper/survey_rates.py b/paper/survey_rates.py new file mode 100644 index 0000000..de14c75 --- /dev/null +++ b/paper/survey_rates.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Denominators and verified reporting rates of the literature survey (Appendix C). + +Reads ``literature/dvv_processing_parameters.csv`` and writes +``paper/data/survey_rates.json`` with the counts the appendix quotes: + +- rows, distinct identifiers (one row per publication) and, among them, the + distinct DOIs (one row, a conference abstract, carries an ADS record); +- rows that measure dv/v (``dvv_method`` not ``n/a``) and the rows that do not + (theory, kernels, deconvolution interferometry, spectral methods); +- rows populated from the full text versus from abstracts; +- among the full-text rows that measure dv/v, the fraction reporting the + frequency band, the coda window and the estimator, and the uncertainty + column coded by the rule below. + +Uncertainty coding rule (applied to the ``uncertainty_treatment`` cell of every +full-text row that measures dv/v): + +``error`` + the paper states an uncertainty on dv/v: a formula, a standard deviation + or standard error, a confidence interval, a regression or bootstrap + error, a stated noise level or significance level; +``qc`` + the paper states only a quality-control rule (a coherence or correlation + threshold, a weighting, a rejection criterion) and no error magnitude; +``none`` + the cell is ``n/r``, ``n/a`` or ``none``. + +The rule is keyword-based with the overrides listed in ``OVERRIDES`` for the +cells the keywords misread; every override is a judgement recorded here so +that it can be disputed. + +Run: python paper/survey_rates.py +""" + +from __future__ import annotations + +import csv +import json +import re +from collections import Counter +from pathlib import Path + +HERE = Path(__file__).resolve().parent +CSV = HERE.parent / "literature" / "dvv_processing_parameters.csv" +OUT = HERE / "data" / "survey_rates.json" + +ERR = re.compile( + r"error|\bsd\b|std|sigma|uncertain|\bci\b|confidence|bootstrap|rms|residual|" + r"regression|formula|weaver|clarke|variance|spread|scatter|misfit|interval|" + r"deviation|jackknife|posterior|bayes|percentile|quantile|propagat|noise level|" + r"\bse\b|\berr\b", + re.I, +) +QC = re.compile( + r"\bcc\b|coheren|threshold|reject|discard|qc|quality|correlation coefficient|" + r">\s*0\.|>=|weight", + re.I, +) +# authors_year prefix -> code, for cells the keyword rule misreads. +OVERRIDES = { + "Lesage et al., 2018": "error", # empirical noise level ~0.05% + "Gassenmeier": "error", # confidence intervals from a Gaussian fit + "Wegler & Sens-Sch": "error", # day-to-day fluctuation ~0.1% stated + "Stehly, Froment": "error", # +-0.1% significance level stated + "Kim & Lekic": "none", # "none (grid-search CC max)" + "Whiteley": "none", + "Illien": "none", + "Obermann et al., 2015": "none", +} + + +def is_blank(v: str) -> bool: + v = (v or "").strip().lower() + return v == "" or v.startswith("n/r") or v.startswith("n/a") + + +def measures_dvv(row: dict) -> bool: + return not row["dvv_method"].strip().lower().startswith("n/a") + + +def code_uncertainty(row: dict) -> str: + for prefix, code in OVERRIDES.items(): + if row["authors_year"].startswith(prefix): + return code + v = row["uncertainty_treatment"] or "" + if is_blank(v): + return "none" + if v.strip().lower().startswith("none"): + # "none (QC only: coh>=0.5)" is a quality rule; "none (data report)" is none. + return "qc" if QC.search(v) else "none" + if ERR.search(v): + return "error" + if QC.search(v): + return "qc" + return "none" + + +def main() -> int: + rows = list(csv.DictReader(CSV.open())) + full = [r for r in rows if r["measurement_source"].lower().startswith("full")] + meas = [r for r in rows if measures_dvv(r)] + meas_full = [r for r in full if measures_dvv(r)] + fields = { + "freq_band_hz": "frequency band", + "coda_window_s": "coda window", + "dvv_method": "estimator", + } + rates = { + name: { + "reported": sum(not is_blank(r[f]) for r in meas_full), + "of": len(meas_full), + } + for f, name in fields.items() + } + unc = Counter(code_uncertainty(r) for r in meas_full) + payload = { + "source": str(CSV.relative_to(HERE.parent)), + "rows": len(rows), + "distinct_identifiers": len({r["doi_url"] for r in rows}), + "distinct_dois": len({r["doi_url"] for r in rows if "10." in r["doi_url"]}), + "rows_measuring_dvv": len(meas), + "rows_not_measuring_dvv": [ + r["authors_year"] for r in rows if not measures_dvv(r) + ], + "rows_full_text": len(full), + "rows_abstract_only": len(rows) - len(full), + "rows_full_text_measuring_dvv": len(meas_full), + "signal_source": dict(Counter(r["signal_source"] for r in rows)), + "signal_source_measuring_dvv": dict(Counter(r["signal_source"] for r in meas)), + "rows_without_doi": [ + r["authors_year"] for r in rows if "10." not in r["doi_url"] + ], + "earthquake_coda_only_measuring": sum( + r["signal_source"] == "Earthquake coda" and measures_dvv(r) for r in rows + ), + "reported_among_full_text_measuring": rates, + "uncertainty_coding_among_full_text_measuring": { + "error_estimate": unc["error"], + "qc_threshold_only": unc["qc"], + "none": unc["none"], + "of": len(meas_full), + }, + "uncertainty_codes": { + r["authors_year"]: code_uncertainty(r) for r in meas_full + }, + } + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(payload, indent=1, ensure_ascii=False)) + print( + json.dumps( + {k: v for k, v in payload.items() if k != "uncertainty_codes"}, + indent=1, + ensure_ascii=False, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/review/REVISION_PLAN_ITER3.md b/review/REVISION_PLAN_ITER3.md index 6296028..ba8bca0 100644 --- a/review/REVISION_PLAN_ITER3.md +++ b/review/REVISION_PLAN_ITER3.md @@ -157,3 +157,4 @@ The submission decision should rest on a defensible measurement claim, transpare - 2026-09-11: Rewritten for Marine Denolle's observational priorities, with explicit measurement targets, diagnostic failure responses, provenance requirements, and evidence-based completion criteria. No experiments or implementation tasks are marked complete by this rewrite. - 2026-09-11: GitHub issues opened under milestone "Iteration 3 revision": P0 #38, P1 #39, P2 #40, P3 #41, P4 #42, P5 #43, P6 #44, P7 #45; author inputs #46 (Gate 1 provenance and rights, blocks P3), #47 (Yuan 2021 Table B3, blocks P1 and P4), #48 (survey rows and search rules, blocks P4), #49 (Fig 8 keep, trim or drop, blocks P0), #50 (step-error metric, blocks P1). Status is tracked in the issues; this file changes only by ticking boxes and appending to this record. - 2026-09-13: P0 executed on branch `iter3/P0` (commits 10a9db3, c66b409) for issue #38; every P0 item addressed except the Fig 8 keep/trim/drop decision (#49), where the composite was kept and made legible pending the decision. PDF rebuilt: 83 pages, no `??`, no `,%`, no `<<`; the Acknowledgements placeholder remains for P6. +- 2026-09-13: P4 executed on branch `iter3/P4` (commit 04edd37) for issue #42: DOI-aware key reuse (Obermann2013b), denominators (103 publications, 96 measuring, 82 full text, 75 full text measuring), coded uncertainty rates (57 / 11 / 7), documented provenance. Row verification and the search queries stay with the author in #48; the Yuan regime with #47. diff --git a/tests/test_build_survey.py b/tests/test_build_survey.py new file mode 100644 index 0000000..d7a4328 --- /dev/null +++ b/tests/test_build_survey.py @@ -0,0 +1,77 @@ +"""Key assignment of the survey bibliography builder (paper/build_survey.py).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "paper" / "build_survey.py" + + +@pytest.fixture(scope="module") +def bs(): + spec = importlib.util.spec_from_file_location("build_survey", SCRIPT) + assert spec is not None and spec.loader is not None, SCRIPT + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +BIB = """ +@article{Obermann2013, + title = {Depth sensitivity of seismic coda waves}, + journal = {GJI}, year = {2013}, + doi = {10.1093/gji/ggt043} +} +@article{Snieder2002, + title = {Coda wave interferometry}, year = {2002} +} +""" + + +def _row(label, year, doi): + return {"authors_year": label, "year": year, "doi_url": doi} + + +def test_existing_dois_parses_keys_and_dois(bs): + d = bs.existing_dois(BIB) + assert d == {"Obermann2013": "10.1093/gji/ggt043", "Snieder2002": None} + + +def test_same_surname_year_different_doi_is_not_reused(bs): + reuse = bs.existing_dois(BIB) + rows = [ + _row("Obermann et al., 2013", "2013", "https://doi.org/10.1002/2013JB010399"), + _row("Obermann, Planès 2013", "2013", "https://doi.org/10.1093/gji/ggt043"), + ] + out = bs.assign_keys(rows, reuse) + assert [(k, reused) for k, _, reused in out] == [ + ("Obermann2013b", False), + ("Obermann2013", True), + ] + + +def test_matching_doi_or_missing_bib_doi_is_reused(bs): + reuse = bs.existing_dois(BIB) + rows = [ + _row("Obermann, Planès 2013", "2013", "https://doi.org/10.1093/gji/ggt043"), + _row("Snieder et al., 2002", "2002", "https://doi.org/10.1126/science.1070015"), + ] + out = bs.assign_keys(rows, reuse) + assert [(k, reused) for k, _, reused in out] == [ + ("Obermann2013", True), + ("Snieder2002", True), + ] + + +def test_two_new_papers_with_one_key_are_disambiguated(bs): + rows = [ + _row("Wang et al., 2017", "2017", "10.1/a"), + _row("Wang & Li 2017", "2017", "10.1/b"), + _row("Wang et al., 2017", "2017", "10.1/a"), # same paper twice + _row("Wang, Chen 2017", "2017", "10.1/c"), # a third distinct paper + ] + out = bs.assign_keys(rows, {}) + assert [k for k, _, _ in out] == ["Wang2017", "Wang2017b", "Wang2017", "Wang2017c"]