Skip to content

Commit 903d84e

Browse files
planet.jeroenclaude
andcommitted
feat(viewer): APP-08 report generation as self-contained HTML
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dd9d500 commit 903d84e

File tree

3 files changed

+151
-0
lines changed

3 files changed

+151
-0
lines changed

packages/pro/src/humeris/adapters/cesium_viewer.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@ def generate_interactive_html(
667667
<div class="session-buttons">
668668
<button class="btn btn-sm" onclick="saveSession()">Save Session</button>
669669
<button class="btn btn-sm" onclick="loadSession()">Load Session</button>
670+
<button class="btn btn-sm" onclick="generateReport()">Report</button>
670671
</div>
671672
<!-- Recent Scenarios (APP-04) -->
672673
<details>
@@ -1323,6 +1324,11 @@ def generate_interactive_html(
13231324
}});
13241325
container.innerHTML = html;
13251326
}}
1327+
// --- Report (APP-08) ---
1328+
function generateReport() {{
1329+
window.open("/api/report", "_blank");
1330+
}}
1331+
13261332
// --- Constraints (APP-07) ---
13271333
function addConstraint() {{
13281334
var metric = document.getElementById("cst-metric").value.trim();

packages/pro/src/humeris/adapters/viewer_server.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,77 @@ def reconfigure_constellation(
932932
self.layers[dep_id].sat_names = sat_names
933933
self.layers[dep_id].czml = dep_czml
934934

935+
def generate_report(
936+
self, name: str = "Constellation Report", description: str = "",
937+
) -> str:
938+
"""Generate a self-contained HTML report of the current session.
939+
940+
Includes: scenario metadata, layer parameters, metrics tables,
941+
constraint pass/fail results.
942+
"""
943+
from html import escape
944+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
945+
parts: list[str] = []
946+
parts.append(f"""<!DOCTYPE html><html><head><meta charset="utf-8">
947+
<title>{escape(name)}</title>
948+
<style>
949+
body {{ font-family: -apple-system, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; color: #333; }}
950+
h1 {{ color: #1a3a5c; border-bottom: 2px solid #1a3a5c; padding-bottom: 8px; }}
951+
h2 {{ color: #2d5f8a; margin-top: 24px; }}
952+
table {{ border-collapse: collapse; width: 100%; margin: 8px 0; }}
953+
th, td {{ padding: 6px 12px; border: 1px solid #ddd; text-align: left; font-size: 13px; }}
954+
th {{ background: #f0f4f8; font-weight: 600; }}
955+
.pass {{ color: #2a7; }} .fail {{ color: #c44; }}
956+
.meta {{ color: #666; font-size: 12px; margin-bottom: 16px; }}
957+
</style></head><body>
958+
<h1>{escape(name)}</h1>
959+
<div class="meta">Generated: {ts} | Epoch: {self.epoch.isoformat()}</div>""")
960+
if description:
961+
parts.append(f"<p>{escape(description)}</p>")
962+
963+
with self._lock:
964+
# Layers
965+
for layer in self.layers.values():
966+
parts.append(f"<h2>{escape(layer.name)}</h2>")
967+
parts.append(f"<p>Type: {layer.layer_type} | Category: {layer.category}</p>")
968+
# Parameters
969+
visible_params = {k: v for k, v in layer.params.items() if not k.startswith("_")}
970+
if visible_params:
971+
parts.append("<table><tr><th>Parameter</th><th>Value</th></tr>")
972+
for k, v in visible_params.items():
973+
parts.append(f"<tr><td>{escape(k)}</td><td>{escape(str(v))}</td></tr>")
974+
parts.append("</table>")
975+
# Metrics
976+
if layer.metrics:
977+
parts.append("<table><tr><th>Metric</th><th>Value</th></tr>")
978+
for k, v in layer.metrics.items():
979+
parts.append(f"<tr><td>{escape(k)}</td><td>{escape(str(v))}</td></tr>")
980+
parts.append("</table>")
981+
982+
# Constraints
983+
if self.constraints:
984+
parts.append("<h2>Constraints</h2>")
985+
# Find first constellation for evaluation
986+
const_id = None
987+
for lid, layer in self.layers.items():
988+
if layer.category == "Constellation":
989+
const_id = lid
990+
break
991+
if const_id:
992+
results = self.evaluate_constraints(const_id)
993+
passed = sum(1 for r in results if r["passed"])
994+
parts.append(f"<p>{passed}/{len(results)} constraints met</p>")
995+
parts.append("<table><tr><th>Metric</th><th>Operator</th><th>Threshold</th><th>Actual</th><th>Result</th></tr>")
996+
for r in results:
997+
icon = '<span class="pass">PASS</span>' if r["passed"] else '<span class="fail">FAIL</span>'
998+
actual = str(r["actual"]) if r["actual"] is not None else "N/A"
999+
parts.append(f'<tr><td>{escape(r["metric"])}</td><td>{escape(r["operator"])}</td>'
1000+
f'<td>{r["threshold"]}</td><td>{actual}</td><td>{icon}</td></tr>')
1001+
parts.append("</table>")
1002+
1003+
parts.append("</body></html>")
1004+
return "\n".join(parts)
1005+
9351006
def add_constraint(self, constraint: dict[str, Any]) -> None:
9361007
"""Add a metric constraint {metric, operator, threshold}."""
9371008
self.constraints.append(constraint)
@@ -1503,6 +1574,21 @@ def do_GET(self) -> None:
15031574
self._error_response(404, f"Layer not found: {param}")
15041575
return
15051576

1577+
if base == "/api/report":
1578+
html_report = self.layer_manager.generate_report(
1579+
name="Constellation Report",
1580+
)
1581+
self.send_response(200)
1582+
self.send_header("Content-Type", "text/html")
1583+
self.send_header("Content-Disposition", 'attachment; filename="report.html"')
1584+
port = self.server.server_address[1]
1585+
self.send_header("Access-Control-Allow-Origin", f"http://localhost:{port}")
1586+
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
1587+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
1588+
self.end_headers()
1589+
self.wfile.write(html_report.encode())
1590+
return
1591+
15061592
self._error_response(404, "Not found")
15071593

15081594
# --- POST ---

tests/test_viewer_server.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3302,3 +3302,62 @@ def test_constraints_restored_from_session(self):
33023302
mgr2 = LayerManager(epoch=EPOCH)
33033303
mgr2.load_session(session)
33043304
assert len(mgr2.constraints) == 1
3305+
3306+
3307+
# ---------------------------------------------------------------------------
3308+
# APP-08: Report generation
3309+
# ---------------------------------------------------------------------------
3310+
3311+
3312+
class TestReportGeneration:
3313+
"""Tests for HTML report generation."""
3314+
3315+
def test_generate_report_returns_html(self):
3316+
"""generate_report returns valid HTML string."""
3317+
from humeris.adapters.viewer_server import LayerManager
3318+
mgr = LayerManager(epoch=EPOCH)
3319+
states = _make_states(n_planes=2, n_sats=2)
3320+
mgr.add_layer(
3321+
name="Constellation:Test", category="Constellation",
3322+
layer_type="walker", states=states,
3323+
params={"altitude_km": 550, "inclination_deg": 53, "num_planes": 2, "sats_per_plane": 2},
3324+
)
3325+
html = mgr.generate_report(name="Test Report")
3326+
assert "<html" in html
3327+
assert "Test Report" in html
3328+
assert "Constellation:Test" in html
3329+
3330+
def test_report_includes_metrics(self):
3331+
"""Report includes metrics for analysis layers."""
3332+
from humeris.adapters.viewer_server import LayerManager
3333+
mgr = LayerManager(epoch=EPOCH)
3334+
states = _make_states(n_planes=2, n_sats=2)
3335+
lid = mgr.add_layer(
3336+
name="Constellation:Test", category="Constellation",
3337+
layer_type="walker", states=states,
3338+
params={"altitude_km": 550, "inclination_deg": 53, "num_planes": 2, "sats_per_plane": 2},
3339+
)
3340+
mgr.add_layer(
3341+
name="Analysis:Beta", category="Analysis",
3342+
layer_type="beta_angle", states=states, params={}, source_layer_id=lid,
3343+
)
3344+
html = mgr.generate_report(name="Metrics Report")
3345+
assert "beta" in html.lower()
3346+
3347+
def test_report_includes_constraints(self):
3348+
"""Report includes constraint pass/fail results."""
3349+
from humeris.adapters.viewer_server import LayerManager
3350+
mgr = LayerManager(epoch=EPOCH)
3351+
states = _make_states(n_planes=2, n_sats=2)
3352+
lid = mgr.add_layer(
3353+
name="Constellation:Test", category="Constellation",
3354+
layer_type="walker", states=states,
3355+
params={"altitude_km": 550, "inclination_deg": 53, "num_planes": 2, "sats_per_plane": 2},
3356+
)
3357+
mgr.add_layer(
3358+
name="Analysis:Beta", category="Analysis",
3359+
layer_type="beta_angle", states=states, params={}, source_layer_id=lid,
3360+
)
3361+
mgr.add_constraint({"metric": "beta_angle_avg_beta_deg", "operator": "<=", "threshold": 90.0})
3362+
html = mgr.generate_report(name="Constraint Report")
3363+
assert "constraint" in html.lower() or "Constraint" in html

0 commit comments

Comments
 (0)