diff --git a/collection-schema.json b/collection-schema.json index 49ff9ad..ada56b5 100644 --- a/collection-schema.json +++ b/collection-schema.json @@ -1,13 +1,13 @@ { "$id": "https://github.com/sigmf/SigMF/spec/1.2.0/collection-schema", - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "http://json-schema.org/draft-07/schema#", "default": {}, "required": ["collection"], "type": "object", "properties": { "collection": { "default": {}, - "description": "The `sigmf-collection` file contains metadata in a single top-level Object called a `collection`. The Collection Object contains key/value pairs that describe relationships between SigMF Recordings.\\nn The Collection Object associates SigMF Recordings together by specifying `SigMF Recording Objects` in the `core:streams` JSON array. Each Object describes a specific associated SigMF Recording.\\nn The following rules apply to SigMF Collections:\n\n 1. The Collection Object MUST be the only top-level Object in the file.\n\n 2. Keys in the Collection Object SHOULD use SigMF Recording Objects when referencing SigMF Recordings.\n\n 3. SigMF Recording Objects MUST contain both a `name` field, which is the base-name of a SigMF Recording, and a `hash` which is the SHA512 hash of the Recording Metadata file `[base-name].sigmf-meta`.\n\n 4. SigMF Recording Objects MUST appear in a JSON array.\\nn Example `top-level.sigmf-collection` file:\\begin{verbatim}{\n\"collection\": {\n \"core:version\": \"1.2.0\",\n \"core:extensions\" : [\n {\n \"name\": \"antenna\",\n \"version\": \"1.0.0\",\n \"optional\": true\n }\n ],\n \"antenna:hagl\": 120,\n \"antenna:azimuth_angle\": 98,\n \"core:streams\": [\n {\n \"name\": \"example-channel-0-basename\",\n \"hash\": \"b4071db26f5c7b0c70f5066eb9bc3a8b506df0f5af09991ba481f63f97f7f48e9396584bc1c296650cd3d47bc4ad2c5b72d2561078fb6eb16151d2898c9f84c4\"\n },\n {\n \"name\": \"example-channel-1-basename\",\n \"hash\": \"7132aa240e4d8505471cded716073141ae190f763bfca3c27edd8484348d6693d0e8d3427d0bf1990e687a6a40242d514e5d1995642bc39384e9a37a211655d7\"\n }\n ]\n }\n}\\end{verbatim}", + "description": "The `sigmf-collection` file contains metadata in a single top-level Object called a `collection`. The Collection Object contains key/value pairs that describe relationships between SigMF Recordings.\\nn The Collection Object associates SigMF Recordings together by specifying `SigMF Recording Objects` in the `core:streams` JSON array. Each Object describes a specific associated SigMF Recording.\\nn The following rules apply to SigMF Collections:\n\n 1. The Collection Object MUST be the only top-level Object in the file.\n\n 2. Keys in the Collection Object SHOULD use SigMF Recording Objects when referencing SigMF Recordings.\n\n 3. SigMF Recording Objects MUST contain both a `name` field, which is the base-name of a SigMF Recording, and a `hash` which is the SHA512 hash of the Recording Metadata file `[base-name].sigmf-meta`.\n\n 4. SigMF Recording Objects MUST appear in a JSON array.\\nn Example `top-level.sigmf-collection` file:\\begin{verbatim}{\n\"collection\": {\n \"core:version\": \"1.2.0\",\n \"core:extensions\" : [\n {\n \"name\": \"antenna\",\n \"version\": \"1.0.0\",\n \"optional\": true\n }\n ],\n \"antenna:hagl\": 120,\n \"antenna:azimuth_angle\": 98,\n \"core:streams\": [\n {\n \"name\": \"example-channel-0-basename\",\n \"hash\": \"b4071db26f5c7b0c70f5066eb9bc3a8b506df0f5af09991...\"\n },\n {\n \"name\": \"example-channel-1-basename\",\n \"hash\": \"7132aa240e4d8505471cded7160731h41ae190f763bfc46...\"\n }\n ]\n }\n}\\end{verbatim}", "required": ["core:version"], "type": "object", "properties": { diff --git a/docs-generator.py b/docs-generator.py index 23d5ef3..9018ac3 100644 --- a/docs-generator.py +++ b/docs-generator.py @@ -1,226 +1,723 @@ +from __future__ import annotations + +""" +Generate SigMF specification documentation (PDF + HTML) directly from the +JSON-Schema source files. +""" + +from pathlib import Path import json +import re import subprocess +from typing import Any, Dict, Set -from pylatex import (Command, Document, Figure, Package, Section, Subsection, - Subsubsection, Tabular) +from pylatex import ( + Command, + Document, + Figure, + Package, + Section, + Subsection, + Subsubsection, + +) from pylatex.utils import NoEscape, bold -with open("sigmf-schema.json", "r") as f: - data = json.load(f) -with open("collection-schema.json", "r") as f: - data_collection = json.load(f) -with open("extensions/antenna-schema.json", "r") as f: - data_antenna = json.load(f) +# --------------------------------------------------------------------------- +# Configuration – change these when the repository layout changes +# --------------------------------------------------------------------------- +REPO_ROOT = Path(__file__).resolve().parent +SCHEMA_PATHS = { + "core": REPO_ROOT / "sigmf-schema.json", + "collection": REPO_ROOT / "collection-schema.json", + "antenna": REPO_ROOT / "extensions" / "antenna-schema.json", + "capture_detail": REPO_ROOT / "extensions" / "capture-detail-schema.json", + "signal": REPO_ROOT / "extensions" / "signal-schema.json", + "spatial": REPO_ROOT / "extensions" / "spatial-schema.json", + "traceability": REPO_ROOT / "extensions" / "traceability-schema.json", +} +ADDITIONAL_CONTENT_MD = REPO_ROOT / "additional_content.md" +LOGO_PATH = REPO_ROOT / "logo" / "sigmf_logo.png" +OUTPUT_BASENAME = "sigmf-spec" + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + + +def _read_json(path: Path) -> Dict[str, Any]: + """Load *and* validate that *path* exists.""" + if not path.exists(): + raise FileNotFoundError(path) + with path.open() as f: + return json.load(f) + + +def _normalise_key(key: str, *prefixes_to_strip: str) -> str: + """Return a human-oriented variant of *key*. + + Prefixes like ``core:`` or ``signal:`` are stripped, duplicate underscores + collapsed, and we keep colons intact so that the SigMF namespace remains + visible in the PDF. + """ + for p in prefixes_to_strip: + key = key.replace(p, "") + # Collapse accidental repeated underscores introduced by replacements + key = re.sub(r"__+", "_", key) + return key + + +def _add_code_tags(text: str) -> str: + """ + Convert markdown-ish text to LaTeX, keeping code blocks intact. + """ + # ------------------------------------------------------------------ + # 1) protect code blocks first + # ------------------------------------------------------------------ + CODE_RE = re.compile( + r"(```.*?```)|" # triple-back-tick markdown blocks + r"(\\begin{verbatim}.*?\\end{verbatim})", + re.DOTALL + ) + + code_store: list[str] = [] -sigmf_version = data["$id"].split("/")[-2] -print("SigMF Version: " + sigmf_version) + def _stash(m: re.Match) -> str: + code = m.group(0) + if code.startswith("```"): + code = code[3:-3] + code = code.strip("\n") + code = f"\\begin{{verbatim}}\n{code}\n\\end{{verbatim}}" + code_store.append(code) + return f"§§CODEBLOCK{len(code_store)-1}§§" + text = CODE_RE.sub(_stash, text) + + # ------------------------------------------------------------------ + # 2) Clean up problematic sequences from JSON schema descriptions + # ------------------------------------------------------------------ + text = text.replace("_", r"\_") + + # Remove ALL line break commands that appear in schema descriptions + text = text.replace("\\newline", " ") + # This appears in collection-schema.json + text = text.replace("\\nn", " ") + text = text.replace("\\\\", " ") + + # Remove embedded LaTeX table commands that appear in schema descriptions + text = re.sub(r"\\rowcolors\{[^}]*\}\{[^}]*\}\{[^}]*\}", "", text) + text = re.sub(r"\\begin\{center\}.*?\\end\{center\}", + "", text, flags=re.DOTALL) + text = re.sub(r"\\begin\{tabular\}.*?\\end\{tabular\}", + "", text, flags=re.DOTALL) + text = re.sub(r"\\begin\{samepage\}.*?\\end\{samepage\}", + "", text, flags=re.DOTALL) + text = re.sub(r"\\toprule|\\midrule|\\bottomrule", "", text) + + # Clean up paragraph breaks + text = re.sub(r"\n{2,}", r"\\par\\vspace{2mm}", text) + text = text.replace("\n", " ") + + # Handle inline code + text = re.sub(r"(? str: + """Return short description up to first period or whole string.""" + desc = field.get("description", "").replace("\n", " ") + dot = desc.find(".") + return desc if dot == -1 else desc[:dot] + +# --------------------------------------------------------------------------- +# Main generator class +# --------------------------------------------------------------------------- + + +class SigMFDocGenerator: + """Create SigMF LaTeX (and afterwards HTML) documentation from schemas.""" + + def __init__(self, schema_paths: dict[str, Path], logo_path: Path, extra_md: Path) -> None: + self.schemas = {name: _read_json(p) + for name, p in schema_paths.items()} + self.logo_path = logo_path + self.extra_md_parts = extra_md.read_text().split( + "<<<<<<<<<>>>>>>>>>>>" + ) + self.sigmf_version: str = self.schemas["core"]["$id"].split("/")[-2] + + self._labels: Set[str] = set() + self._current_schema: str = "core" + self.doc = self._create_document_skeleton() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def build_pdf_and_html(self) -> None: + """Generate *OUTPUT_BASENAME*.pdf and *OUTPUT_BASENAME*.html.""" + tex_file = self._populate_document() + + # Try PDF generation + try: + self.doc.generate_pdf(OUTPUT_BASENAME, clean_tex=False, + compiler_args=["--shell-escape"]) + print(f"PDF generated successfully: {OUTPUT_BASENAME}.pdf") + except subprocess.CalledProcessError as e: + # Check if PDF was actually generated despite errors + pdf_path = Path(f"{OUTPUT_BASENAME}.pdf") + if pdf_path.exists(): + print( + f"PDF generated with warnings: {OUTPUT_BASENAME}.pdf") + print( + f"LaTeX had some issues but PDF was created (exit code: {e.returncode})") + else: + print(f"PDF generation failed: {e}") + except Exception as e: + print(f" PDF generation failed: {e}") + + # Generate HTML + try: + self._compile_html(tex_file) + print("HTML generated successfully") + except Exception as e: + print(f"HTML generation failed: {e}") + raise + + # ------------------------------------------------------------------ + # Internal helpers – document structure + # ------------------------------------------------------------------ + + def _create_document_skeleton(self) -> Document: + geometry_options = {k: "1in" for k in ( + "tmargin", "lmargin", "rmargin", "bmargin")} + doc = Document(geometry_options=geometry_options) + + # Preamble essentials ------------------------------------------------ + doc.preamble.append(Command("title", "SigMF")) + for pkg in ( + ("underscore", None), + ("xcolor", ["table"]), + ("listings", None), + ("microtype", None), + ("fancyhdr", None), + ("booktabs", None), + ("longtable", None), + ("svg", None), + ( + "hyperref", + ["hidelinks", "colorlinks=true", "urlcolor=blue", "linkcolor=black"], + ), + ): + name, opts = pkg + doc.packages.append(Package(name, options=opts)) + + # Colours & custom macros ------------------------------------------- + doc.append(NoEscape("\\definecolor{mylightgray}{RGB}{240,240,240}")) + doc.append(NoEscape("\\definecolor{lightblue}{RGB}{240,240,255}")) + doc.append( + NoEscape( + "\\newcommand{\\code}[1]{\\texttt{\\colorbox{mylightgray}{#1}}}") + ) + doc.append( + NoEscape("\\newcommand{\\sectionspacing}[0]{\\par\\vspace{4mm}\\noindent}")) + + # Footer ------------------------------------------------------------- + doc.append(NoEscape("\\pagestyle{fancy}")) + doc.append(NoEscape("\\fancyhf{}")) + doc.append(NoEscape("\\renewcommand{\\headrulewidth}{0pt}")) + doc.append(NoEscape("\\fancyfoot[LE,RO]{\\thepage}")) doc.append( NoEscape( - "This document is available under the \href{http://creativecommons.org/licenses/by-sa/4.0/}{CC-BY-SA License}. Copyright of contributions to SigMF are retained by their original authors. All contributions under these terms are welcome." + f"\\fancyfoot[LO,CE]{{\\footnotesize SigMF Specification Version {self.sigmf_version}}}" ) ) + doc.append(NoEscape( + "\\newcommand{\\subsubsubsection}[1]{\\vspace{2mm}\\par\\noindent\\textbf{#1}\\par\\noindent}")) + # Number down to paragraph level + doc.append(NoEscape("\\setcounter{secnumdepth}{4}")) + # Show paragraphs in TOC + doc.append(NoEscape("\\setcounter{tocdepth}{4}")) - with doc.create(Subsection("Table of Contents")): - doc.append(NoEscape("\\vspace{-0.4in}\\def\\contentsname{\\empty}\\setcounter{tocdepth}{3}\\tableofcontents")) - - doc.append(NoEscape(add_code_tags(open("additional_content.md", "r").read().split("<<<<<<<<<>>>>>>>>>>>")[0]))) - - with doc.create(Subsection("Global Object")): - doc.append(NoEscape(add_code_tags(data["properties"]["global"]["description"]))) - doc.append("\n\n") - doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) - with doc.create(Tabular("lllp{3.8in}")) as table: - gen_table(table, data["properties"]["global"]) - gen_fields(doc, data["properties"]["global"]) - - with doc.create(Subsection("Captures Array")): - doc.append(NoEscape(add_code_tags(data["properties"]["captures"]["description"]))) - doc.append("\n\n") - doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) - with doc.create(Tabular("lllp{3.8in}")) as table: - gen_table(table, data["properties"]["captures"]["items"]) - gen_fields(doc, data["properties"]["captures"]["items"]) - - with doc.create(Subsection("Annotations Array")): - doc.append(NoEscape(add_code_tags(data["properties"]["annotations"]["description"]))) - doc.append("\n\n") - doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) - with doc.create(Tabular("lllp{3.8in}")) as table: - gen_table(table, data["properties"]["annotations"]["items"]) - gen_fields(doc, data["properties"]["annotations"]["items"]) - - with doc.create(Subsection("SigMF Collection Format")): - doc.append(NoEscape(add_code_tags(data_collection["properties"]["collection"]["description"]))) - doc.append("\n\n") - doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) - with doc.create(Tabular("lllp{3.8in}")) as table: - gen_table(table, data_collection["properties"]["collection"]) - gen_fields(doc, data_collection["properties"]["collection"]) - - doc.append(NoEscape(add_code_tags(open("additional_content.md", "r").read().split("<<<<<<<<<>>>>>>>>>>>")[1]))) - -with doc.create(Section("Extensions")): - with doc.create(Subsection("Antenna")): - doc.append(NoEscape(add_code_tags(data_antenna["properties"]["global"]["description"]))) - doc.append("\n\n") - doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) - with doc.create(Tabular("lllp{2.8in}")) as table: - gen_table(table, data_antenna["properties"]["global"]) - gen_fields(doc, data_antenna["properties"]["global"]) - - doc.append(NoEscape("\\nn")) - doc.append(NoEscape(add_code_tags(data_antenna["properties"]["annotations"]["description"]))) - doc.append("\n\n") - doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) - with doc.create(Tabular("lllp{3.8in}")) as table: - gen_table(table, data_antenna["properties"]["annotations"]["items"]) - gen_fields(doc, data_antenna["properties"]["annotations"]["items"]) - - doc.append(NoEscape("\\nn")) - doc.append(NoEscape(add_code_tags(data_antenna["properties"]["collection"]["description"]))) - doc.append("\n\n") - doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) - with doc.create(Tabular("lllp{3.8in}")) as table: - gen_table(table, data_antenna["properties"]["collection"]) - gen_fields(doc, data_antenna["properties"]["collection"]) - -print("Generating...") -try: - doc.generate_pdf("sigmf-spec", clean_tex=False, compiler_args=["--shell-escape"]) # clean_tex will remove the generated tex file -except subprocess.CalledProcessError as e: - print(e) # this seems normal to occur - -# 2nd time, so table of contents loads -try: - doc.generate_pdf("sigmf-spec", clean_tex=False, compiler_args=["--shell-escape"]) # clean_tex will remove the generated tex file -except subprocess.CalledProcessError as e: - print(e) # this seems normal to occur - -# Create CSS file -css_string = """ -#TOC { - position: fixed; - width: 20em; - left: -1em; - top: 0; - height: 100%; - background-color: white; - overflow-y: scroll; - padding: 0; -} -#subsec\:TableofContents { - display: none; -} -body { - padding-left: 20em; -} -@media (max-width:800px){ - #TOC {display:none; width: 0em;} - body {padding-left: 0em;} -} -code { - color: #000; - font-family: monospace; - background: #f4f4f4; -} -tr:nth-of-type(odd) { - background-color:#f0f0ff; -} -""" -with open("main.css", "w") as f: - f.write(css_string) - -# Generate HTML from tex with Pandoc -css_url = "https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css" -pandoc_out = subprocess.run( - f"pandoc sigmf-spec.tex -f latex -t html -s -o sigmf-spec.html --toc --toc-depth=3 -c {css_url} -c main.css".split(), - capture_output=True, - text=True, -) -if len(pandoc_out.stderr): - raise Exception("Pandoc error: " + pandoc_out.stderr) + return doc + + # ------------------------------------------------------------------ + # Content adding helpers + # ------------------------------------------------------------------ + + def _populate_document(self) -> Path: + """Fill *self.doc* with all sections and return resulting *Path* to TEX.""" + + self._add_cover_and_toc() + self._add_core_sections() + self._add_extensions() + + tex_path = Path(f"{OUTPUT_BASENAME}.tex") + self.doc.generate_tex(tex_path.stem) # Only write .tex; compile later + return tex_path + + def _add_cover_and_toc(self) -> None: + with self.doc.create(Figure(position="h!")) as logo: + self.doc.append(NoEscape("\\vspace{-0.8in}\\centering")) + logo.add_image(str(self.logo_path), width="120px") + self.doc.append(NoEscape("\\vspace{-0.3in}")) + + with self.doc.create(Section(f"SigMF Specification Version {self.sigmf_version}")): + self._add_subsection( + "Abstract", self.schemas["core"].get("description", "")) + copyright_md = ( + "This document is available under the " + "\\href{http://creativecommons.org/licenses/by-sa/4.0/}{CC-BY-SA License}. " + "Copyright of contributions to SigMF are retained by their original authors. " + "All contributions under these terms are welcome." + ) + self._add_subsection("Copyright Notice", copyright_md) + + with self.doc.create(Subsection("Table of Contents")): + self.doc.append( + NoEscape( + "\\vspace{-0.4in}\\def\\contentsname{\\empty}\\setcounter{tocdepth}{4}\\tableofcontents") + ) + + # Extra MD before core schema content + self.doc.append(NoEscape(_add_code_tags(self.extra_md_parts[0]))) + + def _add_core_sections(self) -> None: + self._current_schema = "core" # Set context for core sections + core = self.schemas["core"] + collection = self.schemas["collection"] + + self._schema_object_section( + "Global Object", core["properties"]["global"]) + self._schema_array_section( + "Captures Array", core["properties"]["captures"]) + self._schema_array_section( + "Annotations Array", core["properties"]["annotations"] + ) + + self._current_schema = "collection" # Change context for collection + self._schema_object_section( + "SigMF Collection Format", collection["properties"]["collection"] + ) + + # Extra MD between core and extensions + self.doc.append(NoEscape(_add_code_tags(self.extra_md_parts[1]))) + + def _add_extensions(self) -> None: + with self.doc.create(Section("Extensions")): + self._build_antenna_extension() + self._build_capture_detail_extension() + self._build_signal_extension() + self._build_spatial_extension() + self._build_traceability_extension() + + def _add_subsection(self, title: str, content: str) -> None: + with self.doc.create(Subsection(title)): + if content: + self.doc.append(NoEscape(_add_code_tags(content))) + # ------------------------------------------------------------------ + # Individual extension builders (each fairly small now!) + # ------------------------------------------------------------------ + + def _build_antenna_extension(self) -> None: + self._current_schema = "antenna" + ant = self.schemas["antenna"] + with self.doc.create(Subsection("Antenna")): + self._extension_intro(ant) + self._schema_object_subsubsection( + "Global", ant["properties"]["global"]) + self._schema_array_subsubsection( + "Annotations", ant["properties"]["annotations"]) + self._schema_object_subsubsection( + "Collection", ant["properties"]["collection"]) + + def _build_capture_detail_extension(self) -> None: + self._current_schema = "capture_detail" + + cap = self.schemas["capture_detail"] + with self.doc.create(Subsection("Capture Detail")): + self._extension_intro(cap) + self._schema_array_subsubsection( + "Captures", cap["properties"]["captures"]) + self._schema_array_subsubsection( + "Annotations", cap["properties"]["annotations"]) + + def _build_signal_extension(self) -> None: + self._current_schema = "signal" + sig = self.schemas["signal"] + with self.doc.create(Subsection("Signal")): + self._extension_intro(sig) + annot_props = sig["properties"]["annotations"]["items"]["properties"] + self._schema_object_subsubsection( + "Signal Detail", annot_props["signal:detail"]) + self._schema_object_subsubsection( + "Signal Emitter", annot_props["signal:emitter"]) + + def _build_spatial_extension(self) -> None: + self._current_schema = "spatial" + spat = self.schemas["spatial"] + with self.doc.create(Subsection("Spatial")): + self._extension_intro(spat) + + # Definitions as subsubsection - FIX: Use unique label system + label = self._unique_label("ssubsec", "Definitions") + with self.doc.create(Subsubsection("Definitions", label=False)): + self.doc.append(NoEscape(f"\\label{{{label}}}")) + + self._definition_object( + "Bearing Object", spat["$defs"]["bearing"], "bearing") + self._definition_object( + "Cartesian Point Object", spat["$defs"]["cartesian_point"], "cartesian_point") + self._definition_object( + "Calibration Object", spat["$defs"]["calibration"], "calibration") + + # Schema sections as subsubsections + self._schema_object_subsubsection( + "Global", spat["properties"]["global"]) + self._schema_array_subsubsection( + "Captures", spat["properties"]["captures"]) + self._schema_array_subsubsection( + "Annotations", spat["properties"]["annotations"]) + self._schema_object_subsubsection( + "Collection", spat["properties"]["collection"]) + + def _build_traceability_extension(self) -> None: + self._current_schema = "traceability" + tr = self.schemas["traceability"] + with self.doc.create(Subsection("Traceability")): + self._extension_intro(tr) + + # Definitions as subsubsection - FIX: Use unique label system + label = self._unique_label("ssubsec", "Definitions") + with self.doc.create(Subsubsection("Definitions", label=False)): + self.doc.append(NoEscape(f"\\label{{{label}}}")) + + self._definition_object( + "DataChange Object", tr["$defs"]["DataChange"], "datachange") + self._definition_object( + "Origin Object", tr["$defs"]["Origin"], "origin") + + # Schema sections as subsubsections + self._schema_object_subsubsection( + "Global", tr["properties"]["global"]) + self._schema_array_subsubsection( + "Annotations", tr["properties"]["annotations"]) + + # ------------------------------------------------------------------ + # Table / field generation helpers + # ------------------------------------------------------------------ + + def _schema_object_section(self, title: str, schema: Dict[str, Any]) -> None: + descr = schema.get("description", "") + with self.doc.create(Subsection(title)): + if descr: + self.doc.append(NoEscape(_add_code_tags(descr))) + # Replace \nn + self.doc.append(NoEscape("\\vspace{4mm}\\par\\noindent")) + self._object_table(schema) + self._field_details(schema) + + def _schema_array_section(self, title: str, schema: Dict[str, Any]) -> None: + descr = schema.get("description", "") + with self.doc.create(Subsection(title)): + if descr: + self.doc.append(NoEscape(_add_code_tags(descr))) + items = schema["items"]["anyOf"][0] if "anyOf" in schema["items"] else schema["items"] + # Replace \nn + self.doc.append(NoEscape("\\vspace{4mm}\\par\\noindent")) + self._object_table(items) + self._field_details(items) + + def _extension_intro(self, schema: Dict[str, Any]) -> None: + self.doc.append( + NoEscape(_add_code_tags(schema.get("description", "")))) + self.doc.append(NoEscape("\\vspace{4mm}\\par\\noindent")) + + def _definition_object(self, title: str, schema: Dict[str, Any], label_name: str) -> None: + """Add a definition object with proper label and no subsection.""" + self.doc.append(NoEscape("\\vspace{2mm}\\par\\noindent")) + self.doc.append(NoEscape(f"\\textbf{{{title}}}")) + self.doc.append(NoEscape(f"\\label{{def:{label_name}}}")) + self.doc.append(NoEscape("\\vspace{2mm}\\par\\noindent")) + self._object_table(schema) + self._field_details_no_subsection(schema) + + def _schema_object_subsubsection(self, title: str, schema: Dict[str, Any]) -> None: + """Schema object as subsubsection with paragraph field details (for extensions).""" + descr = schema.get("description", "") + label = self._unique_label("ssubsec", title) + + with self.doc.create(Subsubsection(title, label=False)): + self.doc.append(NoEscape(f"\\label{{{label}}}")) + + if descr: + self.doc.append(NoEscape(_add_code_tags(descr))) + self.doc.append(NoEscape("\\vspace{4mm}\\par\\noindent")) + self._object_table(schema) + # Use paragraph-level field details for extensions + self._field_details_paragraphs(schema) + + def _schema_array_subsubsection(self, title: str, schema: Dict[str, Any]) -> None: + """Schema array as subsubsection with paragraph field details (for extensions).""" + descr = schema.get("description", "") + label = self._unique_label("ssubsec", title) + + with self.doc.create(Subsubsection(title, label=False)): + self.doc.append(NoEscape(f"\\label{{{label}}}")) + + if descr: + self.doc.append(NoEscape(_add_code_tags(descr))) + items = schema["items"]["anyOf"][0] if "anyOf" in schema["items"] else schema["items"] + self.doc.append(NoEscape("\\vspace{4mm}\\par\\noindent")) + self._object_table(items) + # Use paragraph-level field details for extensions + self._field_details_paragraphs(items) + + # ---------- LaTeX element builders ------------------------------------ + + def _object_table(self, schema: Dict[str, Any]) -> None: + self.doc.append(NoEscape("\\rowcolors{1}{}{lightblue}")) + + # Use longtable for automatic page breaks + self.doc.append( + NoEscape("\\begin{longtable}{p{3.5cm}p{1.8cm}p{2.5cm}p{6.5cm}}")) + + # Header + self.doc.append(NoEscape("\\toprule")) + self.doc.append(NoEscape( + "\\textbf{Field} & \\textbf{Required} & \\textbf{Type} & \\textbf{Short Description} \\\\")) + self.doc.append(NoEscape("\\midrule")) + self.doc.append(NoEscape("\\endfirsthead")) + + # Footer + self.doc.append(NoEscape("\\bottomrule")) + self.doc.append(NoEscape("\\endlastfoot")) + + # Now add all the table rows + self._render_longtable_rows(schema) + + self.doc.append(NoEscape("\\end{longtable}")) + + def _definition_table(self, title: str, schema: Dict[str, Any]) -> None: + # Replace \nn + self.doc.append(NoEscape("\\vspace{4mm}\\par\\noindent")) + self.doc.append(NoEscape(f"\\textbf{{{title}}}")) + self.doc.append(NoEscape("\\vspace{2mm}\\par\\noindent")) + self._object_table(schema) + self._field_details(schema) + + def _render_longtable_rows(self, schema: Dict[str, Any]) -> None: + """Render table rows for longtable (without headers/footers)""" + props = schema["properties"] + required_set = set(schema.get("required", [])) + + for field_name, field_schema in props.items(): + if "$ref" in field_schema: + ref_path = field_schema["$ref"] + if ref_path.startswith("#/$defs/"): + type_name = ref_path.split("/")[-1] + # Link to definition + field_type = f"\\hyperref[def:{type_name.lower()}]{{\\texttt{{{type_name}}}}}" + else: + field_type = "Reference" + else: + field_type = field_schema.get("type", "MISSING") + + # Clean description for table use - remove problematic formatting + desc = field_schema.get("description", "").replace("\n", " ") + desc = desc.replace("_", r"\_").replace( + "&", r"\&").replace("#", r"\#") + # Remove backticks that could break tables + desc = desc.replace("`", "") + desc = re.sub(r"\\[a-zA-Z]+\{[^}]*\}", "", + desc) # Remove LaTeX commands + + # Truncate very long descriptions + if len(desc) > 150: + desc = desc[:147] + "..." + + # Just escape underscores + clean_field = _normalise_key(field_name).replace("_", r"\_") + + # Required field text + required_text = "Required" if field_name in required_set else "" + + # Add the row to longtable + self.doc.append( + NoEscape(f"{clean_field} & {required_text} & {field_type} & {desc} \\\\")) + + def _field_details(self, schema: Dict[str, Any]) -> None: + """Emit subsubsections for every property inside *schema* (for core sections).""" + props = schema["properties"] + for field_name, field_schema in props.items(): + clean = _normalise_key(field_name) + label = self._unique_label("ssubsec", clean) + + # Create subsubsection for core sections (appears in TOC) + # Use label=False to prevent PyLaTeX from auto-generating labels + with self.doc.create(Subsubsection(clean, label=False)): + # Now add our unique label + self.doc.append(NoEscape(f"\\label{{{label}}}")) + + if "description" in field_schema: + self.doc.append( + NoEscape(_add_code_tags(field_schema["description"]))) + + # Dump all other keys + for k, v in field_schema.items(): + if k in {"$id", "description", "items", "additionalItems", "pattern"}: + continue + self.doc.append("\n") + self.doc.append(bold(k)) + self.doc.append(": ") + self.doc.append(str(v)) + + def _field_details_paragraphs(self, schema: Dict[str, Any]) -> None: + """Emit paragraph-level sections for every property inside *schema* (for extensions).""" + props = schema["properties"] + for field_name, field_schema in props.items(): + clean = _normalise_key(field_name) + label = self._unique_label("field", clean) + + # Create a paragraph-level section for extensions (appears in TOC) + self.doc.append(NoEscape(f"\\paragraph{{{clean}}}")) + # Fix: Use the unique label, not a hardcoded one + self.doc.append(NoEscape(f"\\label{{{label}}}")) + + # Handle $ref fields specially + if "$ref" in field_schema: + ref_path = field_schema["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + self.doc.append(NoEscape( + f"This field references the \\hyperref[def:{ref_name.lower()}]{{{ref_name}}} object type.")) + else: + self.doc.append(f"References: {ref_path}") + continue + + # Handle regular fields + if "description" in field_schema: + self.doc.append( + NoEscape(_add_code_tags(field_schema["description"]))) + + # Dump all other keys + for k, v in field_schema.items(): + if k in {"$id", "description", "items", "additionalItems", "pattern"}: + continue + self.doc.append("\n") + self.doc.append(bold(k)) + self.doc.append(": ") + self.doc.append(str(v)) + + def _field_details_no_subsection(self, schema: Dict[str, Any]) -> None: + """Field details without creating subsubsections (for definitions).""" + props = schema["properties"] + for field_name, field_schema in props.items(): + clean = _normalise_key(field_name) + label = self._unique_label("field", clean) + + # Add field as paragraph instead of subsubsection + self.doc.append(NoEscape("\\vspace{2mm}\\par\\noindent")) + self.doc.append(NoEscape(f"\\textbf{{{clean}}}")) + # Fix: Use the unique label, not a hardcoded one + self.doc.append(NoEscape(f"\\label{{{label}}}")) + self.doc.append(NoEscape("\\par\\noindent")) + + # Handle $ref fields specially - SAME AS _field_details_paragraphs + if "$ref" in field_schema: + ref_path = field_schema["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + self.doc.append(NoEscape( + f"This field references the \\hyperref[def:{ref_name.lower()}]{{{ref_name}}} object type.")) + else: + self.doc.append(f"References: {ref_path}") + continue + + # Handle regular fields + if "description" in field_schema: + self.doc.append( + NoEscape(_add_code_tags(field_schema["description"]))) + + # Add other field properties + for k, v in field_schema.items(): + if k in {"$id", "description", "items", "additionalItems", "pattern"}: + continue + self.doc.append("\n") + self.doc.append(bold(k)) + self.doc.append(": ") + self.doc.append(str(v)) + # ------------------------------------------------------------------ + # Build/compile helpers + # ------------------------------------------------------------------ + + def _compile_html(self, tex_path: Path) -> None: + css_file = Path("main.css") + css_file.write_text(self._bootstrap_css()) + css_url = "https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css" + subprocess.run([ + "pandoc", + tex_path.name, + "-f", + "latex", + "-t", + "html", + "-s", + "-o", + f"{OUTPUT_BASENAME}.html", + "--toc", + "--toc-depth=3", + "-c", + css_url, + "-c", + str(css_file), + ], check=True) + + # ------------------------------------------------------------------ + # Misc helpers + # ------------------------------------------------------------------ + + def _unique_label(self, prefix: str, text: str) -> str: + """Generate a unique, LaTeX-safe label.""" + # Clean the text for LaTeX labels but preserve more context + # Replace colons with underscores, keep alphanumeric and underscores + clean_text = re.sub(r'[^a-zA-Z0-9_]', '', + text.lower().replace(':', '_')) + + # Include current schema context to make labels unique + base = f"{prefix}_{self._current_schema}_{clean_text}" + counter = 1 + label = base + while label in self._labels: + label = f"{base}_{counter}" + counter += 1 + self._labels.add(label) + return label + + def _bootstrap_css(self) -> str: + return ( + "#TOC { position: fixed; width: 20em; left: -1em; top: 0; height: 100%; " + "background-color: white; overflow-y: scroll; padding: 0; }\n" + "#subsec\\:TableofContents { display: none; }\n" + "body { padding-left: 20em; }\n" + "@media (max-width:800px){ #TOC {display:none; width: 0em;} body {padding-left: 0em;} }\n" + "code { color: #000; font-family: monospace; background: #f4f4f4; }\n" + "tr:nth-of-type(odd) { background-color:#f0f0ff; }" + ) + +# --------------------------------------------------------------------------- +# CLI entry‑point +# --------------------------------------------------------------------------- + + +if __name__ == "__main__": + generator = SigMFDocGenerator( + SCHEMA_PATHS, LOGO_PATH, ADDITIONAL_CONTENT_MD) + generator.build_pdf_and_html() diff --git a/extensions/antenna-schema.json b/extensions/antenna-schema.json index 6f5c4ec..3181c3b 100644 --- a/extensions/antenna-schema.json +++ b/extensions/antenna-schema.json @@ -1,6 +1,6 @@ { "$id": "https://github.com/sigmf/SigMF/spec/1.0.0/extensions/antenna-schema", - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "http://json-schema.org/draft-07/schema#", "title": "Anntenna Schema extension for SigMF-meta file.", "description": "The `antenna` namespace extension defines static antenna parameters extending the `global` and `annotations` objects in SigMF Recordings, and the `collection` object in a SigMF Collection.", "type": "object", @@ -8,9 +8,7 @@ "global": { "description": "The following names are specified in the `antenna` namespace and should be used in the `global` object:", "type": "object", - "required": [ - "antenna:model" - ], + "required": ["antenna:model"], "properties": { "antenna:model": { "description": "Antenna make and model number. E.g. ARA CSB-16, L-com HG3512UP-NF.", diff --git a/extensions/capture-detail-schema.json b/extensions/capture-detail-schema.json new file mode 100644 index 0000000..e52144c --- /dev/null +++ b/extensions/capture-detail-schema.json @@ -0,0 +1,75 @@ +{ + "$id": "https://github.com/sigmf/SigMF/spec/1.0.0/extensions/capture-details-schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Capture Details Schema extension for SigMF-meta file", + "description": "The `capture_details` namespace extension defines static IQ capture parameters extending `captures` and dynamic IQ capture parameters extending `annotations`.", + "type": "object", + "properties": { + "captures": { + "description": "The following names are specified in the `capture_details` namespace and should be used in the `captures` object:", + "type": "array", + "items": { + "type": "object", + "required": [ + "capture_details:acq_scale_factor", + "capture_details:attenuation", + "capture_details:acquisition_bandwidth", + "capture_details:start_capture", + "capture_details:stop_capture", + "capture_details:source_file" + ], + "properties": { + "capture_details:acq_scale_factor": { + "type": "number", + "description": "Scale factor of IQ collection from the spectrum analyzer used to convert back to real power." + }, + "capture_details:attenuation": { + "type": "number", + "description": "Attenuation applied to the input of the sensor, in dB." + }, + "capture_details:acquisition_bandwidth": { + "type": "number", + "description": "Bandwidth of the recording (if lower than the `samp_rate`, in Hz." + }, + "capture_details:start_capture": { + "type": "string", + "description": "Time of the first sample of IQ recording. The time is UTC with the format of yyyy-mm-ddTHH:MM:SSZ." + }, + "capture_details:stop_capture": { + "type": "string", + "description": "Time of the last sample of IQ recording. The time is UTC with the format of yyyy-mm-ddTHH:MM:SSZ." + }, + "capture_details:source_file": { + "type": "string", + "description": "RF IQ recording filename that was used to create the file N.sigmf-data. The file N.sigmf-data may be the same or an edited versions of the source_file." + }, + "capture_details:gain": { + "type": "number", + "description": "Gain setting of the sensor for this acquisition, distilled to a single number, in dB." + } + } + } + }, + "annotations": { + "description": "The following names are specified in the `capture_details` namespace and should be used in the `annotations` object:", + "type": "array", + "items": { + "type": "object", + "required": [ + "capture_details:SNRdB", + "capture_details:signal_reference_number" + ], + "properties": { + "capture_details:SNRdB": { + "type": "number", + "description": "Root mean square (RMS) calculation of signal to noise ratio (SNR). The calculation is over windows of known signal and no known signal, in dB." + }, + "capture_details:signal_reference_number": { + "type": "string", + "description": "Sequential reference labels for the elements that form the sequence of signals identified in a SigMF dataset file. The format of the string is the filename followed by an index that increases with each decoded signal." + } + } + } + } + } +} diff --git a/extensions/capture_details.sigmf-ext.md b/extensions/capture_details.sigmf-ext.md deleted file mode 100644 index f0bbf7e..0000000 --- a/extensions/capture_details.sigmf-ext.md +++ /dev/null @@ -1,36 +0,0 @@ -# Capture Details Extension v1.0.0 - -The `capture_details` namespace extension defines static IQ capture parameters -extending `captures` and dynamic IQ capture parameters extending `annotations`. - -## 1 Global - -`capture_details` does not extend [Global](https://github.com/sigmf/SigMF/blob/main/sigmf-spec.md#global-object). - -## 2 Captures - -The following names are specified in the `capture_details` namespace and should -be used in the `captures` object: - -|name|required|type|unit|description| -|----|--------|----|----|-----------| -|`acq_scale_factor`|true|double|N/A|Scale factor of IQ collection from the spectrum analyzer used to convert back to real power.| -|`attenuation`|true|double|dB|Attenuation applied to the input of the sensor.| -|`acquisition_bandwidth`|true|double|Hz|Bandwidth of the recording (if lower than the `samp_rate`.| -|`start_capture`|true|string|N/A|Time of the first sample of IQ recording. The time is UTC with the format of `yyyy-mm-ddTHH:MM:SSZ`.| -|`stop_capture`|true|string|N/A|Time of the last sample of IQ recording. The time is UTC with the format of `yyyy-mm-ddTHH:MM:SSZ`.| -|`source_file`|true|string|N/A|RF IQ recording filename that was used to create the file `N.sigmf-data`. The file `N.sigmf-data` may be the same or an edited versions of the `source_file`.| -|`gain`|false|double|dB|Gain setting of the sensor for this acquisition, distilled to a single number.| - -## 3 Annotations - -The following names are specified in the `capture_details` namespace and should be used in the `annotations` object: - -|name|required|type|unit|description| -|----|--------|----|----|-----------| -|`SNRdB`|true|double|dB|Root mean square (RMS) calculation of signal to noise ratio (SNR). The calculation is over windows of known signal and no known signal.| -|`signal_reference_number`|true|string|N/A|Sequential reference labels for the elements that form the sequence of signals identified in a SigMF dataset file. The format of the string is the filename followed by an index that increases with each decoded signal. An example is a recording dataset file named `N.sigmf-data` would have signal numbers starting with `N-1`, `N-2`, `N-3`...| - -## 4 Examples - -No `capture_details` examples. diff --git a/extensions/signal-schema.json b/extensions/signal-schema.json new file mode 100644 index 0000000..00b7bf8 --- /dev/null +++ b/extensions/signal-schema.json @@ -0,0 +1,131 @@ +{ + "$id": "https://github.com/sigmf/SigMF/spec/1.0.0/extensions/signal-schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Signal Schema extension for SigMF-meta file", + "description": "The signal extension namespace defines how to describe the attributes of wireless communications signals and their emitters", + "type": "object", + "properties": { + "annotations": { + "description": "The following names are specified in the `signal` namespace and should be used in the `annotations` object:", + "type": "array", + "items": { + "type": "object", + "properties": { + "signal:detail": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["analog", "digital"], + "description": "Type of modulation scheme" + }, + "mod_class": { + "type": "string", + "enum": [ + "am", + "fm", + "pm", + "ssb", + "dsb", + "vsb", + "ask", + "fsk", + "psk", + "qam", + "ook", + "cpm", + "msk" + ], + "description": "Modulation class" + }, + "standard": { + "type": "string", + "description": "Communication standard (e.g., 802.11ac)" + }, + "carrier_variant": { + "type": "string", + "enum": [ + "with_carrier", + "suppressed_carrier", + "reduced_carrier", + "single_carrier", + "multi_carrier" + ], + "description": "Carrier variant type" + }, + "symbol_variant": { + "type": "string", + "enum": ["differential", "offset"], + "description": "Symbol variant type" + }, + "order": { + "type": "integer", + "minimum": 0, + "description": "Modulation order" + }, + "duplexing": { + "type": "string", + "enum": ["tdd", "fdd"], + "description": "Duplexing type" + }, + "multiplexing": { + "type": "string", + "enum": ["tdm", "fdm", "cdm", "ofdm", "sdm", "pdm"], + "description": "Multiplexing type" + }, + "multiple_access": { + "type": "string", + "enum": ["fdma", "ofdma", "tdma", "cdma", "sdma", "pdma"], + "description": "Multiple access type" + }, + "spreading": { + "type": "string", + "enum": ["fhss", "thss", "dsss", "css"], + "description": "Spreading type" + }, + "channel_bw": { + "type": "number", + "description": "Channel bandwidth" + }, + "channel": { + "type": "integer", + "minimum": 0, + "description": "Channel number" + }, + "class_variant": { + "type": "string", + "description": "Modifier to modulation class" + } + } + }, + "signal:emitter": { + "type": "object", + "properties": { + "seid": { + "type": "integer", + "minimum": 0, + "description": "Unique ID of the emitter" + }, + "manufacturer": { + "type": "string", + "description": "Manufacturer of the hardware" + }, + "power_tx": { + "type": "number", + "description": "Total transmitted power (dBm)" + }, + "power_eirp": { + "type": "number", + "description": "Effective Isotropic Radiated Power (dBm)" + }, + "geolocation": { + "type": "object", + "description": "GeoJSON location of emitter" + } + } + } + } + } + } + } +} diff --git a/extensions/signal.sigmf-ext.md b/extensions/signal.sigmf-ext.md deleted file mode 100644 index 3e08726..0000000 --- a/extensions/signal.sigmf-ext.md +++ /dev/null @@ -1,277 +0,0 @@ -# The `signal` SigMF Extension Namespace v1.0.0 - -This document defines the `signal` extension namespace for the Signal Metadata -Format (SigMF) specification. This extension namespace defines how to describe -the attributes of wireless communications signals and their emitters. - -## 1 Global - -`signal` does not extend [Global](https://github.com/sigmf/SigMF/blob/main/sigmf-spec.md#global-object). - -## 2 Captures - -`signal` does not extend [Captures](https://github.com/sigmf/SigMF/blob/main/sigmf-spec.md#captures-array). - - -## 3 Annotations - -This extension adds the following optional field to the `annotations` global SigMF object: - -|name|required|type|description| -|----|--------|----|-----------| -|`detail`|false|[Detail](signal.sigmf-ext.md#the-detail-object)|Emission details (standard, modulation, etc.)| -|`emitter`|false|[Emitter](signal.sigmf-ext.md#the-emitter-object)|Emitter details (manufacturer, geo coordinates, etc.)| - -The field of communications is vast, and there may be communications systems -that cannot be described using the names and fields described in this extension. -If you need additional or different fields to describe a system, create a new -extension that adds the necessary fields to the `signal` namespace and/or submit -the new fields to be upstreamed into this canonical extension. - -### 3.1 The Detail Object - -|name|required|type|description| -|----|--------|----|-----------| -|`type`|false|string|[type](signal.sigmf-ext.md#the-type-field)| -|`mod_class`|false|string|[mod_class](signal.sigmf-ext.md#the-mod_class-field)| -|`standard`|false|string|Communication standard (e.g., 802.11ac)| -|`carrier_variant`|false|string|[carrier variant](signal.sigmf-ext.md#the-carrier_variant-field)| -|`symbol_variant`|false|string|[symbol variant](signal.sigmf-ext.md#the-symbol_variant-field)| -|`order`|false|uint|[order](signal.sigmf-ext.md#the-order-field)| -|`duplexing`|false|string|[duplexing](signal.sigmf-ext.md#the-duplexing-field)| -|`multiplexing`|false|string|[multiplexing](signal.sigmf-ext.md#the-multiplexing-field)| -|`multiple_access`|false|string|[multiple access](signal.sigmf-ext.md#the-multiple_access-field)| -|`spreading`|false|string|[spreading](signal.sigmf-ext.md#the-spreading-field)| -|`channel_bw`|false|double|[bandwidth](signal.sigmf-ext.md#the-bandwidth-field)| -|`channel`|false|uint|[channel](signal.sigmf-ext.md#the-channel-field)| -|`class_variant`|false|string|[class variant](signal.sigmf-ext.md#the-class_variant-field)| - -#### 3.1.1 The `type` Field - -The `type` field can have the following values: - -|value|description| -|----|-------| -|`analog`|analog modulation scheme| -|`digital`|digital modulation scheme| - -#### 3.1.2 The `mod_class` Field - -The `mod_class` field can have the following values: - -|value|description| -|----|-------| -|`am`|(analog) amplitude modulation| -|`fm`|(analog) frequency modulation| -|`pm`|(analog) phase modulation| -|`ssb`|single side-band| -|`dsb`|dual side-band| -|`vsb`|vestigial side-band| -|`ask`|amplitude-shift keying| -|`fsk`|frequency-shift keying| -|`psk`|phase-shift keying| -|`qam`|quadrature-amplitude modulation| -|`ook`|on-off keying| -|`cpm`|continuous phase modulation| -|`msk`|minimum-shift keying| - -#### 3.1.3 The `carrier_variant` Field - -The `carrier_variant` field can have the following values: - -|value|description| -|-----|-----------| -|`with_carrier`|with-carrier modulation| -|`suppressed_carrier`|suppressed-carrier modulation| -|`reduced_carrier`|reduced-carrier modulation| -|`single_carrier`|single-carrier modulation| -|`multi_carrier`|multi-carrier modulation| - -#### 3.1.4 The `symbol_variant` Field - -The `symbol_variant` field can have the following values: - -|value|description| -|-----|-----------| -|`differential`|differential modulation| -|`offset`|offset modulation (sometimes called 'staggered')| - -#### 3.1.5 The `order` Field - -The `order` field has an unsigned integer value that describes the modulation -order, which typically refers to the number of symbols or states in a digital -modulation (e.g., QAM64 has 64 symbols, QPSK has 4 symbols). - -#### 3.1.6 The `duplexing` Field - -The `duplexing` field can have the following values: - -|value|description| -|----|-------| -|`tdd`|time-division duplexing| -|`fdd`|frequency-division duplexing| - -#### 3.1.7 The `multiplexing` Field - -The `multiplexing` field can have the following values: - -|value|description| -|----|-------| -|`tdm`|time-division multiplexing| -|`fdm`|frequency-division multiplexing| -|`cdm`|code-division multiplexing| -|`ofdm`|orthogonal frequency-division multiplexing| -|`sdm`|space-division multiplexing| -|`pdm`|polarization-division multiplexing| - -#### 3.1.8 The `multiple_access` Field - -The `multiple_access` field can have the following values: - -|value|description| -|----|-------| -|`fdma`|frequency-division multiple access| -|`ofdma`|orthogonal frequency-division multiple access| -|`tdma`|time-division multiple access| -|`cdma`|code-division multiple access| -|`sdma`|space-division multiple access| -|`pdma`|power-division multiple access| - -#### 3.1.9 The `spreading` Field - -The `spreading` field can have the following values: - -|value|description| -|----|-------| -|`fhss`|frequency-hopping spread spectrum| -|`thss`|time-hopping spread spectrum| -|`dsss`|direct-sequence spread spectrum| -|`css`|chirp spread spectrum| - -#### 3.1.10 The `bandwidth` Field - -The `channel_bw` field has a numeric value describing the channel bandwidth of -the signal. Note that this is different from what may be reported in the `core` -namespace within an annotation which describes the occupied spectrum of a -signal, which may or may not be comparable to the actual channel bandwidth of -the communications system. - -#### 3.1.11 The `channel` Field - -The `channel` field has an unsigned integer value that describes the channel -number of the signal within the communication system. - -#### 3.1.12 The `class_variant` Field - -The `class_variant` field describes any modifier to the modulation class not -covered by any of the other fields. Examples include pi/4-DQPSK and GMSK. - -### 3.2 The Emitter Object - -|name|required|type|description| -|----|--------|----|-----------| -|`seid`|false|uint|Unique ID of the emitter| -|`manufacturer`|false|string|Manufacturer of the hardware used to emit the signal| -|`power_tx`|false|double|Total transmitted power by the emitter (dBm)| -|`power_eirp`|false|double|Effective Isotropic Radiated Power in the direction of the receiver (dBm)| -|`geolocation`|false|GeoJSON|Location of the emitter hardware| - -## 4 Examples - -Here is an example of a relatively simple modulation label, which describes a -10 kHz FM signal using time-division duplexing: -```json -{ - ... - "annotations": [{ - "core:sample_start": 0, - "core:sample_count": 500000, - "core:label": "FM TDD", - "signal:detail": { - "type": "analog", - "mod_class": "fm", - "duplexing": "tdd", - "bandwidth": 10000.0 - } - }] -} -``` - -Another simple example, this time with an emitter object: -```json -{ - ... - "annotations": [{ - "core:sample_start": 0, - "core:sample_count": 1000000, - "core:label": "WIFI", - "signal:detail": { - "type": "digital", - "standard": "802.11ac", - "channel": 8 - }, - "signal:emitter": { - "seid": 1, - "manufacturer": "linksys", - "power_tx": 27.0 - } - }] -} -``` -Here is a more complex example that describes an LTE 5 MHz SC-OFDMA downlink: -```json -{ - ... - "annotations": [{ - "core:sample_start": 0, - "core:sample_count": 2500000, - "core:label": "LTE 12", - "signal:detail": { - "type": "digital", - "mod_class": "qam", - "carrier_variant": "single_carrier", - "order": 16, - "multiple_access": "ofdma", - "bandwidth": 5000000.0, - "system": "LTE Release 12" - } - }] -} -``` -A class variant example describing a pi/4-DQPSK signal: -```json -{ - ... - "annotations": [{ - "core:sample_start": 0, - "core:sample_count": 1000000, - "core:label": "pi/4-DQPSK", - "signal:detail": { - "type": "digital", - "mod_class": "psk", - "order": 4, - "symbol_variant": "differential", - "class_variant": "pi/4" - } - }] -} -``` -An example describing just the ID, power, and location of an emitter: -```json -{ - ... - "annotations": [{ - "core:sample_start": 0, - "core:sample_count": 1000000, - "core:label": "5G-NR", - "signal:emitter": { - "seid": 5428604929, - "power_eirp": 43.0, - "geolocation": { - "type": "point", - "coordinates": [-77.071651, 38.897397] - } - } - }] -} -``` diff --git a/extensions/spatial-schema.json b/extensions/spatial-schema.json new file mode 100644 index 0000000..e89ea58 --- /dev/null +++ b/extensions/spatial-schema.json @@ -0,0 +1,165 @@ +{ + "$id": "https://github.com/sigmf/SigMF/spec/1.0.0/extensions/spatial-schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Spatial Schema extension for SigMF-meta file", + "description": "Extension for storing information about spatially diverse data, specifically for directional antennas and multichannel phase-coherent datasets", + "$defs": { + "bearing": { + "type": "object", + "properties": { + "azimuth": { + "type": "number", + "description": "Azimuth component of the direction in degrees increasing clockwise" + }, + "elevation": { + "type": "number", + "description": "Elevation component of the direction in degrees above horizon" + }, + "range": { + "type": "number", + "description": "Line-of-sight slant range to emitter, if known, in meters" + }, + "range_rate": { + "type": "number", + "description": "Time derivative of line-of-sight slant range to emitter, if known, in meters" + }, + "az_error": { + "type": "number", + "description": "Error or uncertainty in the azimuth component" + }, + "el_error": { + "type": "number", + "description": "Error or uncertainty in the elevation component" + }, + "range_error": { + "type": "number", + "description": "Error or uncertainty in the range component" + }, + "range_rate_error": { + "type": "number", + "description": "Error or uncertainty in the range-rate component" + } + } + }, + "cartesian_point": { + "type": "object", + "properties": { + "point": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3, + "description": "A point defined by three elements [x,y,z] referenced to the spatial CRS" + }, + "unknown": { + "type": "boolean", + "description": "Always set to true - indicates that this point is not known" + } + }, + "oneOf": [{ "required": ["point"] }, { "required": ["unknown"] }] + }, + "calibration": { + "type": "object", + "required": ["caltype"], + "properties": { + "caltype": { + "type": "string", + "enum": ["tone", "xcorr", "ref", "other"], + "description": "Type of calibration signal" + }, + "bearing": { + "$ref": "#/$defs/bearing" + }, + "cal_geometry": { + "$ref": "#/$defs/cartesian_point" + } + } + } + }, + "type": "object", + "properties": { + "global": { + "type": "object", + "description": "The following names are specified in the `spatial` namespace and should be used in the `global` object:", + "required": ["spatial:num_elements", "spatial:channel_index"], + "properties": { + "spatial:num_elements": { + "type": "integer", + "minimum": 1 + }, + "spatial:channel_index": { + "type": "integer", + "minimum": 0 + } + } + }, + "captures": { + "type": "array", + "description": "The following names are specified in the `spatial` namespace and should be used in the `captures` object:", + "items": { + "type": "object", + "properties": { + "spatial:aperture_azimuth": { + "type": "number", + "description": "Azimuth of aperture boresight in degrees east of true north" + }, + "spatial:aperture_bearing": { + "$ref": "#/$defs/bearing" + }, + "spatial:aperture_rotation": { + "type": "number", + "description": "Right-handed rotation of aperture in degrees" + }, + "spatial:emitter_bearing": { + "$ref": "#/$defs/bearing" + }, + "spatial:element_geometry": { + "type": "array", + "items": { "$ref": "#/$defs/cartesian_point" } + }, + "spatial:phase_offset": { + "type": "number", + "description": "Phase offset relative to common phase reference plane" + }, + "spatial:calibration": { + "$ref": "#/$defs/calibration" + } + } + } + }, + "annotations": { + "type": "array", + "description": "The following names are specified in the `spatial` namespace and should be used in the `annotations` object:", + "items": { + "type": "object", + "properties": { + "spatial:signal_azimuth": { + "type": "number", + "description": "Azimuth in degrees east of true north" + }, + "spatial:signal_bearing": { + "$ref": "#/$defs/bearing" + }, + "spatial:emitter_location": { + "type": "object", + "description": "GeoJSON point object for emitter location" + } + } + } + }, + "collection": { + "description": "The following fields are specificed in SigMF Collections:", + "type": "object", + "required": ["spatial:element_geometry"], + "properties": { + "spatial:element_geometry": { + "type": "array", + "items": { "$ref": "#/$defs/cartesian_point" }, + "description": "Array of phase center geometries" + } + } + } + } +} diff --git a/extensions/spatial.sigmf-ext.md b/extensions/spatial.sigmf-ext.md deleted file mode 100644 index e92b06c..0000000 --- a/extensions/spatial.sigmf-ext.md +++ /dev/null @@ -1,465 +0,0 @@ -# The `spatial` SigMF Extension Namespace v1.1.0 - -This document defines the `spatial` extension namespace for the Signal Metadata -Format (SigMF) specification. This extension namespace contains objects to help -store information about spatially diverse data, specifically information applied -to directional antennas and multichannel phase-coherent datasets used for signal -direction of arrival and beamforming. - -Multichannel datasets can be stored in SigMF Collections or as multichannel -interleaved data depending on the application. Collections are RECOMMENDED due -to the easier processing and better application support. - -The `spatial` extension makes use of cartesian coordinates to define array -geometry, and spherical coordinates for reporting bearings. This, coupled with -the various methods for defining boresights and bearing references can become -complicated rapidly so the union of these two coordinate reference systems -(CRS) is illustrated in Figure 1 below. The ISO 80000-2:2019 compliant cartesian -and spherical systems have a specific relationship with the reported azimuth and -elevation, which use the conventional geospatial definitions (degrees east of -true north, and degrees above horizon respectively). - -![SigMF Spatial Coordinate Reference System](spatial_crs.png) - -**Figure 1 - SigMF Spatial Extension Coordinate Reference Systems** - -As shown in Figure 1, the boresight of the aperture is defined as being in the -direction of the positive X-axis. Based on this, as an example, a horizontal -uniform linear array aperture would be defined along the Y-axis (see examples -section below). - -## 0 Datatypes - -This extension defines the following datatypes: - -|name|long-form name|description| -|----|--------------|-----------| -|bearing|signal direction bearing|JSON [bearing](spatial.sigmf-ext.md#01-the-bearing-object) object containing a quantitative representation of a direction with optional error fields.| -|cartesian_point|cartesian position|JSON [cartesian_point](spatial.sigmf-ext.md#02-the-cartesian-point-object) object containing a cartesian coordinate point triplet.| - -### 0.1 The `bearing` Object - -A `bearing` object is used to describe relative one or two dimensional -directions. The angular fields within the `bearing` object are always specified -in degrees, and linear distances in meters. While it is legal for angular fields -to have any value it is RECOMMENDED that these values be wrapped to a consistent -representation (e.g.: between 0 to 360, or +/- 180). - -|name|required|type|units|description| -|----|--------|----|-----|-----------| -|`azimuth`|false|double|degrees|Azimuth component of the direction in degrees increasing clockwise.| -|`elevation`|false|double|degrees|Elevation component of the direction in degrees above horizon.| -|`range`|false|double|meters|Line-of-sight slant range to emitter, if known, in meters.| -|`range_rate`|false|double|meters/second|Time derivative of line-of-sight slant range to emitter, if known, in meters.| -|`az_error`|false|double|degrees|Error or uncertainty in the azimuth component.| -|`el_error`|false|double|degrees|Error or uncertainty in the elevation component.| -|`range_error`|false|double|meters|Error or uncertainty in the range component.| -|`range_rate_error`|false|double|meters/second|Error or uncertainty in the range-rate component.| - -The `az_error`, `el_error`, and `range_error` field units are degrees, but the -exact meaning of 'error' in this context is not explicitly defined. Applications -SHOULD specify their specific meaning, and in general this should be interpreted -as an uncertainty range. The error fields SHOULD NOT be included if the -corresponding estimate fields are not present. - -An example of a `bearing` object is shown below: - -```json - "bearing": { - "azimuth": 211.2, - "elevation": 15.0, - "range": 30, - "range_rate": -1.3, - "az_error": 2.5, - "el_error": 7.5, - "range_error": 0.25, - "range_rate_error": 0.02 - } -``` - -### 0.2 The `cartesian_point` Object - -A `cartesian_point` object is used to describe a single point in the Cartesian -coordinate reference system. This object is necessary to define the phase center -geometries for multidimensional arrays, and other cartesian locations. - -|name|required|type|units|description| -|----|--------|----|-----|-----------| -|`point`|false|array|meters|A point defined by three double elements [x,y,z] referenced to the `spatial` CRS.| -|`unknown`|false|bool|N/A|Always set to `true` - indicates that this point is not known.| - -`cartesian_point` objects represent a point in 3D space, and MUST contain either -a `point` or `unknown` field. `unknown` fields are placeholders when the exact -information is not known or does not matter. - -## 1 Global - -The `spatial` extension adds the following fields to the `global` SigMF object: - -|name|required|type|units|description| -|----|--------|----|-----|-----------| -|`num_elements`|true|int|N/A|Defines the number of phase centers / channels collected in the Collection or multichannel Dataset.| -|`channel_index`|true|int|N/A|The channel number, represents the index into `element_geometry`.| - -The number of elements MUST be defined here and is constant for a given -Collection. It may be tempting to use the `core:num_channels` field however -that field specifies how many interleaved channels are present in a single -Dataset whereas spatial Recordings may be spread over several individual -Datasets in a SigMF Collection. - -In the case of a multichannel dataset, the `channel_index` specifies the first -channel in the dataset. If all data is contained within that dataset then the -`channel_index` field MUST be equal to zero. - -## 2 Captures - -The `spatial` extension adds the following fields to `captures` segment objects: - -|name|required|type|units|description| -|----|--------|----|-----|-----------| -|`aperture_azimuth`|false|double|degrees|Azimuth of the aperture boresight in degrees east of true north.| -|`aperture_bearing`|false|[bearing](spatial.sigmf-ext.md#01-the-bearing-object)|N/A|Bearing of aperture boresight in this segment.| -|`aperture_rotation`|false|double|degrees|Right-handed rotation of the aperture in degrees (other rotational degrees of freedom are captured in `aperture_bearing`).| -|`emitter_bearing`|false|[bearing](spatial.sigmf-ext.md#01-the-bearing-object)|N/A|Bearing of signals in this segment.| -|`element_geometry`|false|array|N/A|An array containing `cartesian_point` objects that specify the relative physical geometry of the antenna elements.| -|`phase_offset`|false|double|degrees|Phase offset of the data in this capture relative to a common phase reference plane.| -|`calibration`|false|[calibration](spatial.sigmf-ext.md#21-the-calibration-object)|Reserved for calibration.| - -The `aperture_bearing` field within a `captures` segment can be used to specify -a fixed aperture boresight bearing. For single element or uniform planar array -apertures, the boresight is defined as the direction of peak gain when fed with -a uniform phase signal; for more complicated arrays this value is determined by -antenna mechanical or electrical geometry and will be specific to the design. -The azimuth is specified in degrees east of true north and elevation (if -provided) is in degrees above the horizon. The `aperture_azimuth` field is also -available for simpler specification when this is more appropriate. - -If the antenna is rotated boresight axis, this can also be specified wit -with the `aperture_rotation` field. This rotation does not affect the antenna -boresight but can be used to indicate the antenna is not perpindicular (e.g.: -mounted upside down if this value is 180 degrees). - -The `emitter_bearing` field within a `captures` segment is used to specify the -ground truth bearing of all signals contained within a multichannel dataset, -relative to the `aperture_bearing`. This is useful for reference data which is -well controlled, but is not well suited for arbitrary signals or data with more -than one emitter location. - -The `element_geometry` object MAY be included in each `captures` segment to -specify phase center geometry at different frequencies. Including this in the -`captures` segment is optional, and is only needed for multi frequency captures -where the element phase centers also move with frequency. This object MUST be -defined if there is not a `sigmf-collection` file that specifies the -`element_geometry`. This array MUST be of length `num_elements` (if specifying -the geometry of the entire array), OR of length `core:num_channels` (if -specifying only the elements contained in this Recording). The `captures` scope -definition of `element_geometry` SHALL take priority over a value specified in -a `collection`. - -The `phase_offset` field is a double precision value used when a dataset is -captured from a RF device that is phase coherent but not phase-aligned. Datasets -making use of this field can be post-processed to align the data and this field -can be set to zero. This value is normally relative to channel 0, and therefore -will be zero for channel 0. If this field is omitted then it is assumed that the -value is zero, and thus it is always OPTIONAL for channel 0 or datasets that are -already phase aligned. Including this value set to zero can explicitly identify -the dataset as phase-aligned. To phase-align the data to the phase reference -plane, the inverse of this factor is applied: exp(-i*radians(phase_offset)). - -### 2.1 The `calibration` Object - -The `calibration` object is a special captures segment metadata field that -indicates the segment is used for calibration. This might be used to show that -a tone or broadband noise signal was generated to perform phase alignment in -post-processing. The resulting value from post-processing calibration can then -be stored in the `captures` object `phase_offset` field. - -If this field is not defined for a `captures` segment, then that segment SHOULD -be treated as normal data. - -|name|required|type|description| -|----|--------|----|-----------| -|`caltype`|true|string|A specific [caltype](spatial.sigmf-ext.md#211-the-caltype-field).| -|`bearing`|false|bearing|The bearing of the calibration signal.| -|`cal_geometry`|false|`cartesian_point`|The position of the calibration antenna phase center relative to the `spatial` CRS.| - -Either the `bearing` or `cal_geometry` field SHOULD be provided if a captures -segment includes the `calibration` field. The `bearing` object is best used to -describe a remote calibration source location in a spherical coordinate system, -while the `cal_geometry` is best used when the calibration emitter is local to -the multi-element array. - -#### 2.1.1 The `caltype` field - -The `caltype` field can have one of the following values: - -|value|description| -|-----|-----------| -|`tone`|This segment contains a tone for calibration purposes.| -|`xcorr`|This segment contains a signal for cross-correlation calibration purposes.| -|`ref`|A known reference emission.| -|`other`|This segment contains another type of calibration signal.| - -## 3 Annotations - -This extension adds the following optional fields to the `annotations` SigMF -object: - -|name|required|type|units|description| -|----|--------|----|-----|-----------| -|`signal_azimuth`|false|double|degrees|Azimuth in degrees east of true north associated with the specific annotation.| -|`signal_bearing`|false|[bearing](spatial.sigmf-ext.md#01-the-bearing-object)|N/A|Bearing associated with the specific annotation.| -|`emitter_location`|false|GeoJSON `point` Object|N/A|The location of the emitter associated with this annotation.| - -These first two fields represent the direction to a specific signal relative to -the `aperture_bearing` and can be utilized when the signals contained in a -Recording need to be defined on a per-signal basis. The `signal_azimuth` field -is provided for simplicity because many applications only require azimuth, but -the `signal_bearing` field is also provided for when elevation, range, and/or -measurement error is needed. Only one of these SHOULD be used for an individual -annotation; if both are provided the `signal_bearing` object has priority. - -The `emitter_location` field uses the same GeoJSON `point` Object as in the `core` -spec and allows for association of a location with the annotation; not the -recorder's position. For example, this field could be used to store -results of TDOA performed on an emission, or it could be used to label a known -position of a transmitter. See `core` spec for more details on GeoJSON `point` -Objects. - -## 4 Collection - -This extension adds the following fields to the SigMF `collection` object: - -|name|required|type|units|description| -|----|--------|----|-----|-----------| -|`element_geometry`|true|array|N/A|An array of `cartesian_point` objects that specify the nominal electrical geometry of the array elements in this collection.| - -The `element_geometry` field describes the phase center geometry of each element -relative to the `spatial` coordinate reference system and MUST be specified for -collections implementing the `spatial` extension, though the `point` may be -`unknown`. Single channel datasets should contain a single `point` at `[0,0,0]`. -If the `captures` object of a Recording defines this field, that value should be -used at a higher priority than what is specified here. - -## 5 Examples - -Here is an example of how the `global` and captures fields can be specified for -a single channel Recording of a four element uniform linear array with element -spacing of 20cm pointed due west with annotated signal emissions originating -from the north-east. Note that the `signal_azimuth` fields show in the -`annotations` metadata reflect an aimuth of approximately 135* instead of the -expected 45* (north-east). This is because annotation azimuths are documented -relative to the boresight of the antenna. To convert to degrees east of true -north, the aperture azimuth must be accounted for: - -`(270 + 135) % 360 = 45` - -```json -{ - "global": { - "core:datatype": "ci16_le", - "core:sample_rate": 40000000, - "antenna:gain": 0, - "spatial:num_elements": 4, - "spatial:channel_index": 0 - }, - "captures": [ - { - "core:sample_start": 0, - "core:frequency": 740000000.0, - "spatial:aperture_azimuth": 270.0, - "spatial:element_geometry": [ - { "point": [0, 0.3,0] } - ] - } - ], - "annotations": [ - { - "core:sample_start": 38012637, - "core:sample_count": 100991, - "core:freq_upper_edge": 731503448.0, - "core:freq_lower_edge": 730165112.0, - "spatial:signal_azimuth": 133.821 - }, - { - "core:sample_start": 780208811, - "core:sample_count": 100018, - "core:freq_upper_edge": 731250189.0, - "core:freq_lower_edge": 730165114.0, - "spatial:signal_azimuth": 135.904 - }, - ... - ] -} -``` - -This is an example of how to report the observed direction of annotations, in -this case they are originating from the 4 o'clock position: - -```json -{ - ... - "annotations": [ - { - "core:sample_start": 0, - "core:sample_count": 50944, - "core:freq_upper_edge": 2401000000, - "core:freq_lower_edge": 2402000000, - "core:description": "burst", - "spatial:signal_bearing": { - "azimuth": 120.2, - "az_error": 0.5 - } - }, - ... - ] -} -``` - -Here is an example of a 6 element uniform planar array pointed approximately due -north that is detecting emissions from an emitter located east north-east from -the aperture (see figure above for reference). - -```json -{ - "global": { - "core:datatype": "ci16_le", - "core:sample_rate": 40000000, - "antenna:gain": 6, - "spatial:num_elements": 6, - "spatial:channel_index": 4 - }, - "captures": [ - { - "core:sample_start": 0, - "core:frequency": 1260000000.0, - "spatial:aperture_bearing": { - "azimuth": 1.13124 - }, - "spatial:element_geometry": [ - { "point": [0, 0.1, 0.05] }, - { "point": [0, 0, 0.05] }, - { "point": [0,-0.1, 0.05] }, - { "point": [0, 0.1,-0.05] }, - { "point": [0, 0,-0.05] }, - { "point": [0,-0.1,-0.05] } - ] - } - ], - "annotations": [ - { - "core:sample_start": 8424351, - "core:sample_count": 88741, - "core:freq_upper_edge": 1248111918.0, - "core:freq_lower_edge": 1245718776.0, - "core:description": "burst", - "spatial:signal_bearing": { - "azimuth": 59.431, - "elevation": 13.681, - "az_error": 1.3421, - "el_error": 4.1192 - } - }, - { - "core:sample_start": 13843284, - "core:sample_count": 96438, - "core:freq_upper_edge": 1271283241.0, - "core:freq_lower_edge": 1268532007.0, - "core:description": "burst", - "spatial:signal_bearing": { - "azimuth": 60.994, - "elevation": 17.324, - "az_error": 0.9694, - "el_error": 3.8474 - } - }, - ... - ] -} -``` - -Here is an example of a four element aperture with square geometry in the XY plane: - -```json -{ - "global": { - "core:datatype": "ci16_le", - "core:sample_rate": 40000000, - "antenna:gain": 6, - "spatial:num_elements": 4, - "spatial:channel_index": 0 - }, - "captures": [ - { - "core:sample_start": 0, - "core:frequency": 160000000.0, - "spatial:element_geometry": [ - { "point": [ 0.25, 0.25,0] }, - { "point": [ 0.25,-0.25,0] }, - { "point": [-0.25,-0.25,0] }, - { "point": [-0.25, 0.25,0] } - ] - } - ], - "annotations": [ - { - "core:sample_start": 38012637, - "core:sample_count": 100991, - "core:freq_upper_edge": 165125034.0, - "core:freq_lower_edge": 165112388.0, - "spatial:signal_azimuth": 202.812 - }, - { - "core:sample_start": 780208811, - "core:sample_count": 100018, - "core:freq_upper_edge": 165125018.0, - "core:freq_lower_edge": 165112371.0, - "spatial:signal_azimuth": 200.142 - }, - { - "core:sample_start": 118009143, - "core:sample_count": 99841, - "core:freq_upper_edge": 165125041.0, - "core:freq_lower_edge": 165112369.0, - "spatial:signal_azimuth": 197.681 - }, - { - "core:sample_start": 158007123, - "core:sample_count": 101041, - "core:freq_upper_edge": 165125023.0, - "core:freq_lower_edge": 165112401.0, - "spatial:signal_azimuth": 195.017 - }, - ... - ] -} -``` - -An example `collection` object implementing the `spatial` extension for a four -element recording: -```JSON -{ - "collection": { - "core:version": "1.2.0", - "core:extensions" : [ - { - "name": "spatial", - "version": "1.0.0", - "optional": true - } - ], - "core:streams": [ - ["example-channel-0-basename", "hash"], - ["example-channel-1-basename", "hash"], - ["example-channel-2-basename", "hash"], - ["example-channel-3-basename", "hash"] - ], - "spatial:element_geometry": [ - { "point": [ 0.5, 0.5,0] }, - { "point": [ 0.5,-0.5,0] }, - { "point": [-0.5,-0.5,0] }, - { "point": [-0.5, 0.5,0] } - ] - } -} -``` diff --git a/extensions/traceability-schema.json b/extensions/traceability-schema.json new file mode 100644 index 0000000..b2f36bb --- /dev/null +++ b/extensions/traceability-schema.json @@ -0,0 +1,78 @@ +{ + "$id": "https://github.com/sigmf/SigMF/spec/1.0.0/extensions/traceability-schema", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Traceability Schema extension for SigMF-meta file", + "description": "Extension providing traceability information for SigMF metadata", + "$defs": { + "DataChange": { + "type": "object", + "required": ["datetime"], + "properties": { + "author": { + "type": "string", + "format": "email", + "description": "Email address of the author who changed the metadata" + }, + "datetime": { + "type": "string", + "format": "date-time", + "description": "Timestamp of the modification in ISO 8601 format" + } + } + }, + "Origin": { + "type": "object", + "required": ["file_path"], + "properties": { + "account": { + "type": "string", + "description": "Account name or identifier" + }, + "container": { + "type": "string", + "description": "Container or repository name" + }, + "file_path": { + "type": "string", + "description": "Path to the file within the container" + } + } + } + }, + "type": "object", + "properties": { + "global": { + "type": "object", + "properties": { + "traceability:last_modified": { + "$ref": "#/$defs/DataChange" + }, + "traceability:last_reviewed": { + "$ref": "#/$defs/DataChange" + }, + "traceability:revision": { + "type": "integer", + "minimum": 0, + "description": "Revision number associated with the metadata" + }, + "traceability:origin": { + "$ref": "#/$defs/Origin" + } + } + }, + "annotations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "traceability:last_modified": { + "$ref": "#/$defs/DataChange" + }, + "traceability:last_reviewed": { + "$ref": "#/$defs/DataChange" + } + } + } + } + } +} diff --git a/extensions/traceability.sigmf-ext.md b/extensions/traceability.sigmf-ext.md deleted file mode 100644 index eb38c25..0000000 --- a/extensions/traceability.sigmf-ext.md +++ /dev/null @@ -1,199 +0,0 @@ -# The `traceability` SigMF Extension Namespace v1.0.0 - -This document proposes a new extension namespace called `traceability` for the Signal Metadata -Format (SigMF) specification. This extension provides traceability information for the metadata. - -## 1 Global - -`traceability` extends the [Global](https://github.com/sigmf/SigMF/blob/main/sigmf-spec.md#global-object) object. - -The following fields are added to the `global` object: - -|name|required|type|description| -|----|--------|----|-----------| -|`traceability:last_modified`|false|[DataChange](#datachange-object)|Captures the author and timestamp of the most recent modification| -|`traceability:last_reviewed`|false|[DataChange](#datachange-object)|Captures the author and timestamp of the most recent review| -|`traceability:revision`|false|integer|Specifies the revision number associated with the metadata| -|`traceability:origin`|false|[Origin](#origin-object)|Provides information about the origin of the data| - -### DataChange Object - -|name|required|type|description| -|----|--------|----|-----------| -|`author`|false|string|Email address of the author who changed the metadata| -|`datetime`|true|string (date-time)|Timestamp of the modification in ISO 8601 format| - -### Origin Object - -|name|required|type|description| -|----|--------|----|-----------| -|`account`|false|string|Account name or identifier| -|`container`|false|string|Container or repository name| -|`file_path`|true|string|Path to the file within the container| - -## 2 Captures - -`traceability` does not extend the [Captures](https://github.com/sigmf/SigMF/blob/main/sigmf-spec.md#captures-array) object. - -## 3 Annotations - -`traceability` extends the [Annotations](https://github.com/sigmf/SigMF/blob/main/sigmf-spec.md#annotations-object) object. - -The following fields are added to each annotation in the `annotations` array: - -|name|required|type|description| -|----|--------|----|-----------| -|`traceability:last_modified`|false|[DataChange](#datachange-object)|Captures the author and timestamp of the most recent modification| -|`traceability:last_reviewed`|false|[DataChange](#datachange-object)|Captures the author and timestamp of the most recent review| - -## 4 Examples - -Here are some examples of using the `traceability` extension: - -- Simple traceability information: - -```json -{ - "global": { - "traceability:last_modified": { - "author": "john.doe@example.com", - "datetime": "2023-05-31T12:00:00Z" - }, - "traceability:last_reviewed": { - "author": "ben.doe@example.com", - "datetime": "2023-05-31T12:30:00Z" - }, - "traceability:revision": 1, - "traceability:origin": { - "file_path": "/data/signal_data.bin" - } - }, - "annotations": [ - { - "traceability:last_modified": { - "author": "jane.doe@example.com", - "datetime": "2023-05-30T10:30:00Z" - }, - "core:label": "Signal of interest", - "core:sample_start": 100, - "core:sample_count": 500 - } - ], -} -``` - -- Multiple annotations with traceability information: - -```json -{ - "global": { - "traceability:last_modified": { - "author": "john.doe@example.com", - "datetime": "2023-05-31T12:00:00Z" - }, - "traceability:last_reviewed": { - "author": "ben.doe@example.com", - "datetime": "2023-05-31T12:30:00Z" - }, - "traceability:revision": 1, - "traceability:origin": { - "file_path": "/data/signal_data.bin" - } - }, - "annotations": [ - { - "traceability:last_modified": { - "author": "jane.doe@example.com", - "datetime": "2023-05-30T10:30:00Z" - }, - "core:label": "Signal of interest", - "core:sample_start": 100, - "core:sample_count": 500 - }, - { - "traceability:last_modified": { - "author": "james.smith@example.com", - "datetime": "2023-05-30T15:45:00Z" - }, - "traceability:last_reviewed": { - "author": "ben.doe@example.com", - "datetime": "2023-05-31T12:30:00Z" - }, - "core:label": "Noise artifact", - "core:sample_start": 600, - "core:sample_count": 200 - } - ] -} -``` - -- Traceability information with additional origin details: - -```json -{ - "global": { - "traceability:last_modified": { - "author": "john.doe@example.com", - "datetime": "2023-05-31T12:00:00Z" - }, - "traceability:revision": 1, - "traceability:origin": { - "account": "user123", - "container": "sigmf_data", - "file_path": "/data/signal_data.bin" - } - }, - "annotations": [ - { - "traceability:last_modified": { - "author": "jane.doe@example.com", - "datetime": "2023-05-30T10:30:00Z" - }, - "core:label": "Signal of interest", - "core:sample_start": 100, - "core:sample_count": 500 - } - ] -} -``` - -- Multiple modifications of the metadata: - -```json -{ - "global": { - "traceability:last_modified": { - "author": "john.doe@example.com", - "datetime": "2023-05-31T12:00:00Z" - }, - "traceability:revision": 2, - "traceability:origin": { - "file_path": "/data/signal_data.bin" - } - }, - "annotations": [ - { - "traceability:last_modified": { - "author": "jane.doe@example.com", - "datetime": "2023-05-30T10:30:00Z" - }, - "traceability:last_reviewed": { - "author": "ben.doe@example.com", - "datetime": "2023-05-31T12:30:00Z" - }, - "core:label": "Signal of interest", - "core:sample_start": 100, - "core:sample_count": 500 - }, - { - "traceability:last_modified": { - "author": "jane.doe@example.com", - "datetime": "2023-05-31T09:15:00Z" - }, - "core:label": "Updated signal of interest", - "core:sample_start": 50, - "core:sample_count": 700 - } - ] -} -``` diff --git a/sigmf-schema.json b/sigmf-schema.json index 1fb7cdd..83d6718 100644 --- a/sigmf-schema.json +++ b/sigmf-schema.json @@ -1,29 +1,19 @@ { "$id": "https://raw.githubusercontent.com/sigmf/SigMF/v1.2.5/sigmf-schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "http://json-schema.org/draft-07/schema#", "title": "Schema for SigMF Meta Files", "description": "SigMF specifies a way to describe sets of recorded digital signal samples with metadata written in JSON. SigMF can be used to describe general information about a collection of samples, the characteristics of the system that generated the samples, features of signals themselves, and the relationship between different recordings.", "type": "object", - "required": [ - "global", - "captures", - "annotations" - ], + "required": ["global", "captures", "annotations"], "properties": { "global": { "description": "The `global` object consists of key/value pairs that provide information applicable to the entire Dataset. It contains the information that is minimally necessary to open and parse the Dataset file, as well as general information about the Recording itself.", - "required": [ - "core:datatype", - "core:version" - ], + "required": ["core:datatype", "core:version"], "type": "object", "properties": { "core:datatype": { "description": "The SigMF Dataset format of the stored samples in the Dataset file.", - "examples": [ - "cf32_le", - "ri16_le" - ], + "examples": ["cf32_le", "ri16_le"], "default": "cf32_le", "pattern": "^(c|r)(f32|f64|i32|i16|u32|u16|i8|u8)(_le|_be)?", "type": "string" @@ -36,10 +26,7 @@ }, "core:author": { "description": "A text identifier for the author potentially including name, handle, email, and/or other ID like Amateur Call Sign", - "examples": [ - "Bruce Wayne bruce@waynetech.com", - "Bruce (K3X)" - ], + "examples": ["Bruce Wayne bruce@waynetech.com", "Bruce (K3X)"], "type": "string" }, "core:collection": { @@ -65,9 +52,7 @@ }, "core:license": { "description": "A URL for the license document under which the Recording is offered. (RFC 3986)", - "examples": [ - "https://creativecommons.org/licenses/by-sa/4.0/" - ], + "examples": ["https://creativecommons.org/licenses/by-sa/4.0/"], "format": "uri", "type": "string" }, @@ -117,16 +102,11 @@ "core:geolocation": { "description": "The location of the Recording system (note, using the Captures scope `geolocation` field is preferred). See the `geolocation` field within the Captures metadata for details. While using the Captures scope `geolocation` is preferred, fixed recording systems may still provide position information within the Global object so it is RECOMMENDED that applications check and use this field if the Captures `geolocation` field is not present.", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "Point" - ] + "enum": ["Point"] }, "coordinates": { "type": "array", @@ -152,11 +132,7 @@ "additionalItems": false, "items": { "type": "object", - "required": [ - "name", - "version", - "optional" - ], + "required": ["name", "version", "optional"], "properties": { "name": { "description": "The name of the SigMF extension namespace.", @@ -164,9 +140,7 @@ }, "version": { "description": "The version of the extension namespace specification used.", - "examples": [ - "1.2.0" - ], + "examples": ["1.2.0"], "type": "string" }, "optional": { @@ -187,9 +161,7 @@ "additionalItems": false, "items": { "type": "object", - "required": [ - "core:sample_start" - ], + "required": ["core:sample_start"], "properties": { "core:sample_start": { "default": 0, @@ -200,9 +172,7 @@ }, "core:datetime": { "description": "An ISO-8601 string indicating the timestamp of the sample index specified by sample_start. This key/value pair MUST be an ISO-8601 string, as defined by [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt), where the only allowed `time-offset` is `Z`, indicating the UTC/Zulu timezone. The ABNF description is: \\begin{verbatim} date-fullyear = 4DIGIT \n date-month = 2DIGIT ; 01-12 \n date-mday = 2DIGIT ; 01-28, 01-29, 01-30, 01-31 based on month/year \n\n time-hour = 2DIGIT ; 00-23 \n time-minute = 2DIGIT ; 00-59 \n time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second rules \n\n time-secfrac = \".\" 1*DIGIT \n time-offset = \"Z\" \n\n partial-time = time-hour \":\" time-minute \":\" time-second [time-secfrac] \n full-date = date-fullyear \"-\" date-month \"-\" date-mday \n full-time = partial-time time-offset \n\n date-time = full-date \"T\" full-time \\end{verbatim} Thus, timestamps take the form of `YYYY-MM-DDTHH:MM:SS.SSSZ`, where any number of digits for fractional seconds is permitted. ", - "examples": [ - "1955-11-05T14:00:00.000Z" - ], + "examples": ["1955-11-05T14:00:00.000Z"], "pattern": "^([\\+-]?\\d{4}(?!\\d{2}\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24\\:?00)([\\.,]\\d+(?!:))?)?(\\17[0-5]\\d([\\.,]\\d+)?)?([zZ]|([\\+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?", "type": "string" }, @@ -211,10 +181,7 @@ "type": "number", "minimum": -1000000000000, "maximum": 1000000000000, - "examples": [ - 915000000, - 2400000000 - ] + "examples": [915000000, 2400000000] }, "core:global_index": { "description": "The index of the sample referenced by `sample_start` relative to an original sample stream. The entirety of which may not have been captured in a recorded Dataset. If omitted, this value SHOULD be treated as equal to `sample_start`. For example, some hardware devices are capable of 'counting' samples at the point of data conversion. This sample count is commonly used to indicate a discontinuity in the datastream between the hardware device and processing. For example, in the below Captures array, there are two Segments describing samples in a SigMF Dataset file. The first Segment begins at the start of the Dataset file. The second segment begins at sample index 500 relative to the recorded samples (and since this is a conforming SigMF Dataset, is physically located on-disk at location `sample_start * sizeof(sample)`), but the `global_index` reports this was actually sample number 1000 in the original datastream, indicating that 500 samples were lost before they could be recorded. \\begin{verbatim} ...\n \"captures\": [ \n { \n \"core:sample_start\": 0, \n \"core:global_index\": 0 \n }, \n { \n \"core:sample_start\": 500, \n \"core:global_index\": 1000 \n }\n ],\n ... \\end{verbatim} ", @@ -231,16 +198,11 @@ "core:geolocation": { "description": "The location of the recording system at the start of this Captures segment, as a single RFC 7946 GeoJSON `point` Object. For moving emitters, this provides a rudimentary means to manage location through different captures segments. While `core:geolocation` is also allowed in the Global object for backwards compatibility reasons, adding it to Captures is preferred. Per the GeoJSON specification, the point coordinates use the WGS84 coordinate reference system and are `longitude`, `latitude` (REQUIRED, in decimal degrees), and `altitude` (OPTIONAL, in meters above the WGS84 ellipsoid) - in that order. An example including the altitude field is shown below: \\begin{verbatim} \"captures\": {\n ...\n \"core:geolocation\": {\n \"type\": \"Point\",\n \"coordinates\": [-107.6183682, 34.0787916, 2120.0]\n }\n ...\n } \\end{verbatim} GeoJSON permits the use of *Foreign Members* in GeoJSON documents per RFC 7946 Section 6.1. Because the SigMF requirement for the `geolocation` field is to be a valid GeoJSON `point` Object, users MAY include *Foreign Member* fields here for user-defined purposes (position valid indication, GNSS SV counts, dillution of precision, accuracy, etc). It is strongly RECOMMENDED that all fields be documented in a SigMF Extension document. *Note:* Objects named `geometry` or `properties` are prohibited Foreign Members as specified in RFC 7946 Section 7.1.", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "Point" - ] + "enum": ["Point"] }, "coordinates": { "type": "array", @@ -271,9 +233,7 @@ "items": { "type": "object", "title": "Annotation", - "required": [ - "core:sample_start" - ], + "required": ["core:sample_start"], "properties": { "core:sample_start": { "default": 0,