Source code for modelly.provenance.odd

"""An ODD protocol skeleton (Grimm et al.'s Overview, Design concepts, Details) for a model.

The seven ODD elements are written from what :mod:`modelly.provenance.odd_facts` finds
in the code; every place only the modeller can fill holds a ``TODO`` marker.
"""

from __future__ import annotations

from modelly.core.model import Model
from modelly.provenance.odd_facts import AgentFacts, ModelFacts, model_facts

TODO = "TODO"
SECTIONS = (
    "Purpose and patterns",
    "Entities, state variables and scales",
    "Process overview and scheduling",
    "Design concepts",
    "Initialization",
    "Input data",
    "Submodels",
)
DESIGN_CONCEPTS = (
    "Basic principles",
    "Emergence",
    "Adaptation",
    "Objectives",
    "Learning",
    "Prediction",
    "Sensing",
    "Interaction",
    "Stochasticity",
    "Collectives",
    "Observation",
)


def _table(header: tuple[str, ...], rows: list[tuple]) -> list[str]:
    """A Markdown table; ``[]`` when there are no rows."""
    if not rows:
        return []
    lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
    lines += [
        "| " + " | ".join(f"`{c}`" if c != "" else "" for c in row) + " |"
        for row in rows
    ]
    return [*lines, ""]


def purpose(facts: ModelFacts) -> list[str]:
    """Element 1: purpose and patterns."""
    doc = facts.doc or f"{TODO}: what the model is for."
    return [doc, "", f"{TODO}: the patterns the model is meant to reproduce.", ""]


def _agent_entity(agent: AgentFacts) -> list[str]:
    """One agent class: summary, columns and attributes."""
    lines = [
        f"### {agent.name}",
        "",
        agent.summary or f"{TODO}: what this agent represents.",
        "",
    ]
    lines.append(f"{agent.count} at initialization.")
    lines.append("")
    lines += _table(("Column", "dtype", "Default"), agent.columns)
    lines += _table(("Attribute", "Type at initialization"), agent.attributes)
    return [*lines, f"{TODO}: meaning and units of each state variable.", ""]


def entities(facts: ModelFacts) -> list[str]:
    """Element 2: agents, global variables, space and time."""
    lines = []
    for agent in facts.agents:
        lines += _agent_entity(agent)
    lines += ["### Global state variables", ""]
    lines += _table(("Attribute", "Type"), facts.globals) or ["None found.", ""]
    lines += ["### Spatial scale", ""]
    spaces = [
        (n, c, ", ".join(f"{k}={v}" for k, v in t.items())) for n, c, t in facts.spaces
    ]
    lines += _table(("Attribute", "Space", "Traits"), spaces) or ["No space found.", ""]
    lines += [f"{TODO}: what one cell or unit of distance represents.", ""]
    lines += ["### Temporal scale", "", f"One step represents {TODO} (time unit).", ""]
    return lines


def process(facts: ModelFacts) -> list[str]:
    """Element 3: the statements of ``step`` in order."""
    if not facts.step:
        return [
            f"{TODO}: the model's step could not be read; describe the schedule.",
            "",
        ]
    lines = ["Each step (`Model.step`, once per time unit) runs:", ""]
    lines += [f"{i}. `{statement}`" for i, statement in enumerate(facts.step, start=1)]
    if facts.dispatched:
        called = ", ".join(f"`{name}`" for name in facts.dispatched)
        lines += ["", f"Agent methods called: {called} (see Submodels)."]
    return [*lines, "", f"{TODO}: the order of updates and why.", ""]


def _observation(facts: ModelFacts) -> str:
    """What the DataCollector records, for the Observation concept."""
    named = [
        f"{level}: {', '.join(names)}"
        for level, names in facts.reporters.items()
        if names
    ]
    return (
        "DataCollector reporters (" + "; ".join(named) + ")." if named else f"{TODO}."
    )


def design(facts: ModelFacts) -> list[str]:
    """Element 4: the eleven design concepts."""
    hints = {
        "Observation": _observation(facts),
        "Stochasticity": "Draws come from `model.random` and `model.rng`, seeded by the "
        f"scenario's `rng`. {TODO}: which processes are random.",
    }
    return [f"- **{c}**: {hints.get(c, f'{TODO}.')}" for c in DESIGN_CONCEPTS] + [""]


def initialization(facts: ModelFacts) -> list[str]:
    """Element 5: scenario parameters and the initial population."""
    name, defaults, values = facts.scenario
    rows = [
        (k, repr(defaults[k]) if k in defaults else "", repr(v))
        for k, v in values.items()
    ]
    lines = [f"Parameters come from `{name}`:", ""]
    lines += _table(("Parameter", "Default", "Value used"), rows) or [
        "No parameters.",
        "",
    ]
    counts = ", ".join(f"{a.count} {a.name}" for a in facts.agents) or "no agents"
    lines += [f"At initialization the model holds {counts}.", ""]
    return [*lines, f"{TODO}: how initial states and positions are chosen.", ""]


def input_data(facts: ModelFacts) -> list[str]:
    """Element 6: external data (none can be detected from the code)."""
    return [f"{TODO}: external data read during the run, or 'None'.", ""]


def submodels(facts: ModelFacts) -> list[str]:
    """Element 7: each agent method, scheduled ones first."""
    lines = []
    for agent in facts.agents:
        order = {name: i for i, name in enumerate(facts.dispatched)}
        methods = sorted(agent.methods, key=lambda m: order.get(m[0], len(order)))
        for method, doc in methods:
            lines += [f"### {agent.name}.{method}", "", doc or f"{TODO}.", ""]
            lines += [f"{TODO}: equations, parameters and their values.", ""]
    return lines or [f"{TODO}: describe each process of the schedule.", ""]


WRITERS = (purpose, entities, process, design, initialization, input_data, submodels)


[docs] def odd_markdown(model: Model) -> str: """The ODD skeleton of a built model, as Markdown.""" facts = model_facts(model) lines = [ f"# ODD protocol: {facts.name}", "", f"Skeleton generated by `modelly odd` from the code. Replace every {TODO}.", "", ] for number, (title, writer) in enumerate(zip(SECTIONS, WRITERS), start=1): lines += [f"## {number}. {title}", "", *writer(facts)] return "\n".join(lines).rstrip("\n") + "\n"