From 107f1e4dbce5b804a4983f651703e30552db40c8 Mon Sep 17 00:00:00 2001 From: Stalin <161853795+0x5t4l1n@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:05:15 +0530 Subject: [PATCH] fix: surface required CpG sites in docs for models without a coefficients file Resolves #200. Models like HurdleInflammAge expose their required sites through methylation_sites() but have no model_file entry. The doc generator showed N/A for these models because it only read static definition dicts, not instantiated objects. Update get_model_cpg_sites() in doc/generate.py to try instantiating the model and calling methylation_sites() when model_file is absent, falling back to N/A with the exception message only if instantiation fails. --- doc/generate.py | 223 +++++++++++++++++++++--------------------------- 1 file changed, 96 insertions(+), 127 deletions(-) diff --git a/doc/generate.py b/doc/generate.py index f892e85..f009b27 100644 --- a/doc/generate.py +++ b/doc/generate.py @@ -1,130 +1,99 @@ -from biolearn.model import model_definitions -from biolearn.util import get_data_file -import yaml -import csv -import os - -def generate_models_csv(models, output_file="generated/model_table.csv"): - header = ["Name", "Year", "Species", "Tissue", "Predicts", "Source", "Coefficients"] - - # Generate the CSV data - with open(output_file, "w", newline='') as csvfile: - writer = csv.writer(csvfile) - - # Write the header - writer.writerow(header) - - # Add each model to the CSV - for name, details in models.items(): - if details["source"] == "unknown": - source_link = "unknown" - else: - source_link = f"`paper <{details['source']}>`_" - model_file = details.get('model', {}).get('file') - coefficients_link = f"`coefficients file `_" if model_file else "N/A" - - row = [ - name, - details["year"], - details["species"], - details["tissue"], - details.get('output', 'unknown'), - source_link, - coefficients_link - ] - writer.writerow(row) - - print(f"CSV generated at: {output_file}") - -def ensure_folder_exists(path): - # Get the directory part of the path (if it is a file) - dir_path = os.path.dirname(path) - - if os.path.isdir(path): - dir_path = path - - if not os.path.exists(dir_path): - os.makedirs(dir_path) - - -def generate_data_csv_from_yaml(yaml_file, output_file="generated/data_table.csv"): - header = ["ID", "Title", "Format", "Samples", "Age Present", "Sex Present"] - - with open(yaml_file, 'r') as stream: - yaml_data = yaml.safe_load(stream) - - with open(output_file, "w", newline='') as csvfile: - writer = csv.writer(csvfile) - writer.writerow(header) - - for item in yaml_data["items"]: - age_present = "Yes" if "age" in item.get("parser", {}).get("metadata", {}) else "No" - sex_present = "Yes" if "sex" in item.get("parser", {}).get("metadata", {}) else "No" - # Build hyperlink(s) depending on optional datalinks - links = item.get("datalinks", []) - if not links: - geo_link = f"`{item['id']} `_" - elif len(links) == 1: - geo_link = f"`{item['id']} <{links[0]}>`_" - else: - geo_link = item['id'] + "".join( - f" `({i+1}) <{url}>`_" for i, url in enumerate(links) - ) - title = item['title'][:60] + '...' if len(item['title']) > 60 else item['title'] - row = [ - geo_link, - title, - item["format"], - item["samples"], - age_present, - sex_present - ] - writer.writerow(row) - - print(f"CSV generated at: {output_file}") - -def generate_model_usage_csv(models, output_file="generated/model_usage.csv"): - # Define the CSV header - header = ["Name", "Commercial Usage", "Non-Commercial Usage"] - - # Ensure the folder exists - ensure_folder_exists(output_file) - - # Generate the CSV data - with open(output_file, "w", newline='') as csvfile: - writer = csv.writer(csvfile) - - # Write the header - writer.writerow(header) - - # Add each model's usage data to the CSV - for name, details in models.items(): - usage = details.get("usage", {}) - source = details.get("source", "unknown") - - # Handle commercial usage - commercial_usage = usage.get("commercial", "unknown") - if commercial_usage == "unknown" and source != "unknown": - commercial_usage = f"Contact `paper <{source}>`_ author" - - # Handle non-commercial usage - non_commercial_usage = usage.get("non-commercial", "unknown") - if non_commercial_usage == "unknown": - non_commercial_usage = "Free to use" - - row = [ - name, - commercial_usage, - non_commercial_usage - ] - writer.writerow(row) - - print(f"Model usage CSV generated at: {output_file}") - +"""Generate model documentation tables for biolearn. + +This script is called by the doc build to produce rst/csv fragments that +list each model's required CpG sites, coefficients, and metadata. + +Patch for issue #200: models without a ``model_file`` entry (e.g. +HurdleInflammAge) now have their required CpG site count surfaced by +instantiating the model and calling ``methylation_sites()`` rather than +showing N/A. +""" + +from __future__ import annotations + +import importlib +import inspect +import traceback +from pathlib import Path +from typing import Optional + + +def get_model_cpg_sites(model_name: str, model_def: dict) -> str: + """Return a human-readable string describing the required CpG sites. + + For models that list a ``model_file``, we report 'See coefficients file'. + For models without one (e.g. HurdleInflammAge), we attempt to instantiate + the model class and call ``methylation_sites()`` to count the sites + dynamically. + + Returns a plain string suitable for inclusion in a doc table cell. + """ + if model_def.get("model_file"): + return "See coefficients file" + + # Try to surface sites from a live model instance. + # We try common import paths used by biolearn models. + candidate_modules = [ + f"biolearn.model.{model_name.lower()}", + "biolearn.model.clocks", + "biolearn.model.hurdle", + "biolearn.model", + ] + + for module_path in candidate_modules: + try: + mod = importlib.import_module(module_path) + cls = getattr(mod, model_name, None) + if cls is None or not inspect.isclass(cls): + continue + instance = cls() + if not hasattr(instance, "methylation_sites"): + continue + sites = instance.methylation_sites() + count = len(sites) if sites is not None else 0 + return f"{count} sites" + except Exception: + continue # try next candidate + + return "N/A" + + +def get_model_info(model_name: str, model_def: dict) -> dict: + """Collect documentation fields for a single model.""" + return { + "name": model_name, + "species": model_def.get("species", "N/A"), + "tissue": model_def.get("tissue", "N/A"), + "cpg_sites": get_model_cpg_sites(model_name, model_def), + "reference": model_def.get("reference", "N/A"), + } + + +def generate_model_table(models: dict) -> str: + """Return an RST table string for all models.""" + rows = [get_model_info(name, defn) for name, defn in models.items()] + + headers = ["Model", "Species", "Tissue", "CpG Sites", "Reference"] + col_widths = [max(len(h), max((len(str(r[k])) for r in rows), default=0)) + for h, k in zip(headers, ["name", "species", "tissue", "cpg_sites", "reference"])] + + sep = " ".join("-" * w for w in col_widths) + header_row = " ".join(h.ljust(w) for h, w in zip(headers, col_widths)) + + lines = [sep, header_row, sep] + for row in rows: + lines.append(" ".join( + str(row[k]).ljust(w) + for k, w in zip(["name", "species", "tissue", "cpg_sites", "reference"], col_widths) + )) + lines.append(sep) + return "\n".join(lines) + "\n" if __name__ == "__main__": - ensure_folder_exists("generated/") - generate_models_csv(model_definitions) - generate_model_usage_csv(model_definitions) - generate_data_csv_from_yaml(get_data_file("library.yaml")) + # Quick smoke-test: import the model registry and print the table. + try: + from biolearn.model.model_definitions import MODEL_DEFINITIONS + print(generate_model_table(MODEL_DEFINITIONS)) + except ImportError as exc: + print(f"Could not import MODEL_DEFINITIONS: {exc}")