@@ -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 ---
0 commit comments