Source code for biogeme.latent_variables.html_report

from __future__ import annotations

"""Generate a static HTML report from a resolved model."""

from html import escape
from pathlib import Path

from .model_spec import MeasurementModel
from .resolved import ResolvedModel
from .tex_utils import tex_escape, tex_identifier


# Helper for TeX-safe rendering of linear combinations for MathJax
def _combo_to_math_text(combo) -> str:
    """Render a linear combination as TeX-safe math text for MathJax."""
    parts: list[str] = []
    if combo.intercept is not None:
        if hasattr(combo.intercept, 'final_name'):
            parts.append(tex_identifier(combo.intercept.final_name))
        else:
            parts.append(tex_escape(str(combo.intercept.value)))
    for term in combo.terms:
        coef = (
            tex_identifier(term.coefficient.final_name)
            if hasattr(term.coefficient, 'final_name')
            else tex_escape(str(term.coefficient.value))
        )
        parts.append(f'{coef}\\,{tex_identifier(term.variable_name)}')
    return ' + '.join(parts) if parts else '0'


[docs] def generate_html_report(resolved: ResolvedModel) -> str: parts: list[str] = [] parts.append('<!DOCTYPE html>') parts.append('<html><head><meta charset="utf-8">') parts.append( '<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>' ) parts.append( '<style>body{font-family:Arial,sans-serif;max-width:1000px;margin:2rem auto;line-height:1.5} table{border-collapse:collapse;width:100%} th,td{border:1px solid #ccc;padding:0.4rem} code,pre{background:#f6f6f6;padding:0.2rem 0.4rem}</style>' ) parts.append('<title>Latent Variable Model Report</title></head><body>') parts.append('<h1>Latent Variable Model Report</h1>') parts.append( f'<p>The model contains <strong>{resolved.metadata.n_latent_variables}</strong> latent variables, <strong>{resolved.metadata.n_indicators}</strong> indicators, and <strong>{resolved.metadata.n_threshold_systems}</strong> ordinal threshold systems.</p>' ) parts.append('<h2>Structural equations</h2>') for latent_name, latent in resolved.latent_variables.items(): eq = latent.structural_equation rhs = _combo_to_math_text(eq.systematic_part) sigma = tex_identifier(eq.sigma.final_name) if eq.sigma is not None else '0' parts.append( f'<p>\\[{tex_identifier(latent_name)} = {rhs} + {sigma}\\,\\omega_{{{tex_escape(latent_name)}}}\\]</p>' ) parts.append('<h2>Measurement equations</h2>') for indicator_name, equation in resolved.measurement_equations.items(): mu = _combo_to_math_text(equation.systematic_part) if equation.sigma is None: raise ValueError( f"Measurement equation for indicator '{indicator_name}' is missing a resolved sigma parameter." ) sigma = tex_identifier(equation.sigma.final_name) parts.append(f'<h3>{escape(indicator_name)}</h3>') parts.append( f'<p>\\[I^*_{{{tex_escape(indicator_name)}}} = {mu} + {sigma}\\,\\varepsilon_{{{tex_escape(indicator_name)}}}\\]</p>' ) if equation.measurement_model == MeasurementModel.GAUSSIAN: parts.append( f'<p>\\[I_{{{tex_escape(indicator_name)}}} = I^*_{{{tex_escape(indicator_name)}}}\\]</p>' ) elif equation.measurement_model == MeasurementModel.ORDERED_PROBIT: parts.append('<p>Ordered probit measurement model.</p>') else: parts.append('<p>Ordered logit measurement model.</p>') if resolved.threshold_systems: parts.append('<h2>Threshold systems</h2>') for type_name, system in resolved.threshold_systems.items(): parts.append(f'<h3>{escape(type_name)}</h3>') parts.append('<ul>') for cutpoint in system.cutpoints: parts.append( f'<li><code>{escape(cutpoint.symbol_name)}</code> = <code>{escape(tex_escape(cutpoint.expression_text))}</code></li>' ) parts.append('</ul>') parts.append('<h2>Normalization</h2>') if resolved.normalization.rules: parts.append('<ul>') for rule in resolved.normalization.rules: parts.append( f'<li>{escape(rule.reason)} (<code>{escape(rule.target_name)}</code> = <code>{escape(str(rule.value))}</code>)</li>' ) parts.append('</ul>') else: parts.append('<p>No explicit normalization plan was provided.</p>') if resolved.normalization.warnings: parts.append('<h3>Warnings</h3><ul>') for warning in resolved.normalization.warnings: parts.append(f'<li>{escape(warning)}</li>') parts.append('</ul>') parts.append('<h2>Parameters</h2>') parts.append( '<table><tr><th>Name</th><th>Role</th><th>Status</th><th>Bounds</th><th>Notes</th></tr>' ) for name in sorted(resolved.parameters): param = resolved.parameters[name] bounds = f'[{param.lower_bound}, {param.upper_bound}]' notes = '; '.join(param.notes) parts.append( f'<tr><td><code>{escape(name)}</code></td><td>{escape(param.role.value)}</td><td>{escape(param.status.value)}</td><td>{escape(bounds)}</td><td>{escape(notes)}</td></tr>' ) parts.append('</table>') parts.append('</body></html>') return ''.join(parts)
[docs] def save_html_report(report: str, path: str | Path) -> None: """Save HTML report to a file.""" Path(path).write_text(report, encoding='utf-8')