import io import datetime import numpy as np import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from reportlab.lib.pagesizes import letter from reportlab.lib import colors from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle, PageBreak from reportlab.platypus.flowables import HRFlowable def set_dark_theme(): plt.style.use('dark_background') matplotlib.rcParams.update({ 'axes.facecolor': '#0f172a', 'figure.facecolor': '#0f172a', 'axes.edgecolor': '#334155', 'grid.color': '#1e293b', 'text.color': '#f8fafc', 'axes.labelcolor': '#f8fafc', 'xtick.color': '#94a3b8', 'ytick.color': '#94a3b8' }) def plot_verdict_bars(tls_conf, cnn_conf, sig_qual, consistency, fp_rejection): set_dark_theme() fig, ax = plt.subplots(figsize=(6, 3), dpi=300) categories = ['TLS Detection', 'AstroNet Validation', 'Signal Quality', 'Transit Consistency', 'FP Rejection'] scores = [ min(100, max(0, tls_conf)) if tls_conf else 0, min(100, max(0, cnn_conf)) if cnn_conf else 0, min(100, max(0, sig_qual)) if sig_qual else 0, min(100, max(0, consistency)) if consistency else 0, min(100, max(0, fp_rejection)) if fp_rejection is not None else 0 ] colors_list = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444'] y_pos = np.arange(len(categories))[::-1] ax.barh(y_pos, [100]*5, color='#1e293b', height=0.5) ax.barh(y_pos, scores, color=colors_list, height=0.5) ax.set_yticks(y_pos) ax.set_yticklabels(categories, color='#f8fafc', fontweight='bold') ax.set_xlim(0, 100) ax.set_xticks([0, 25, 50, 75, 100]) ax.set_xticklabels(['0%', '25%', '50%', '75%', '100%']) for spine in ax.spines.values(): spine.set_visible(False) for i, v in zip(y_pos, scores): ax.text(v + 2, i, f"{v:.1f}%", color='#f8fafc', va='center', fontweight='bold') plt.tight_layout() buf = io.BytesIO() plt.savefig(buf, format='png') buf.seek(0) plt.close('all') return buf def plot_system_visualizer(a_au, teff, r_star, r_planet_earth): set_dark_theme() fig, ax = plt.subplots(figsize=(6, 4), dpi=300) if r_star and teff: l_star = (r_star**2) * ((teff/5778)**4) hz_inner = np.sqrt(l_star / 1.1) hz_outer = np.sqrt(l_star / 0.53) else: hz_inner, hz_outer = 0.95, 1.37 a_au = a_au if a_au else 1.0 r_planet_earth = r_planet_earth if r_planet_earth else 1.0 max_dist = max(a_au * 1.5, hz_outer * 1.2) star = plt.Circle((0, 0), max_dist*0.05, color='#fbbf24', zorder=10) ax.add_artist(star) hz = plt.Circle((0, 0), hz_outer, color='#10b981', alpha=0.15, zorder=1) ax.add_artist(hz) hz_inner_mask = plt.Circle((0, 0), hz_inner, color='#0f172a', zorder=2) ax.add_artist(hz_inner_mask) orbit = plt.Circle((0, 0), a_au, color='#3b82f6', fill=False, linestyle='--', linewidth=1.5, alpha=0.7, zorder=3) ax.add_artist(orbit) planet_size = max_dist * 0.02 * (r_planet_earth**0.5) planet = plt.Circle((a_au, 0), planet_size, color='#ef4444', zorder=11) ax.add_artist(planet) ax.text(0, max_dist*0.08, "Host Star", color='#fbbf24', ha='center', fontsize=8) ax.text(a_au, planet_size*1.5, "Candidate", color='#ef4444', ha='center', fontsize=8) ax.plot([0, max_dist], [0, 0], color='#cbd5e1', linewidth=0.5, alpha=0.3, zorder=0) ax.text(hz_inner + (hz_outer-hz_inner)/2, -max_dist*0.05, "Habitable Zone", color='#10b981', ha='center', fontsize=8) ax.set_xlim(-max_dist, max_dist) ax.set_ylim(-max_dist, max_dist) ax.set_aspect('equal') ax.axis('off') plt.tight_layout() buf = io.BytesIO() plt.savefig(buf, format='png') buf.seek(0) plt.close('all') return buf def plot_light_curve(time, flux, title, color='#94a3b8', is_scatter=True): set_dark_theme() fig, ax = plt.subplots(figsize=(7, 3), dpi=300) if is_scatter: ax.scatter(time, flux, s=2, color=color, alpha=0.5) else: ax.plot(time, flux, color=color, linewidth=1) ax.set_title(title, color='#f8fafc', pad=10) ax.set_xlabel("Time (days)") ax.set_ylabel("Normalized Flux") ax.grid(True, alpha=0.2) plt.tight_layout() buf = io.BytesIO() plt.savefig(buf, format='png') buf.seek(0) plt.close('all') return buf def plot_tls_spectrum(periods, power, best_period): set_dark_theme() fig, ax = plt.subplots(figsize=(7, 4), dpi=300) ax.plot(periods, power, color='#3b82f6', linewidth=1) if best_period: ax.axvline(best_period, color='#ef4444', linestyle='--', alpha=0.7) ax.text(best_period, max(power)*0.95, f" Best: {best_period:.4f}d", color='#ef4444') ax.set_title("TLS Power Spectrum", color='#f8fafc', pad=10) ax.set_xlabel("Period (days)") ax.set_ylabel("Signal Detection Efficiency (SDE)") ax.grid(True, alpha=0.2) plt.tight_layout() buf = io.BytesIO() plt.savefig(buf, format='png') buf.seek(0) plt.close('all') return buf def plot_batman_fit(phase, flux, model_flux, residuals): set_dark_theme() fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7, 5), dpi=300, gridspec_kw={'height_ratios': [3, 1]}) ax1.scatter(phase, flux, s=5, color='#94a3b8', alpha=0.5, label='Data') sort_idx = np.argsort(phase) phase_sorted = np.array(phase)[sort_idx] model_sorted = np.array(model_flux)[sort_idx] ax1.plot(phase_sorted, model_sorted, color='#ef4444', linewidth=2, label='Batman Model') ax1.set_title("Batman Transit Fit", color='#f8fafc', pad=10) ax1.set_ylabel("Normalized Flux") ax1.set_xlim(-0.1, 0.1) ax1.legend(loc='lower right') ax1.grid(True, alpha=0.2) ax2.scatter(phase, residuals, s=5, color='#3b82f6', alpha=0.5) ax2.axhline(0, color='#f8fafc', linestyle='--', alpha=0.3) ax2.set_xlabel("Phase") ax2.set_ylabel("Residuals") ax2.set_xlim(-0.1, 0.1) ax2.grid(True, alpha=0.2) plt.tight_layout() buf = io.BytesIO() plt.savefig(buf, format='png') buf.seek(0) plt.close('all') return buf def generate_scientific_report(target_name: str, mission: str, analysis_data: dict) -> bytes: buffer = io.BytesIO() doc = SimpleDocTemplate(buffer, pagesize=letter, rightMargin=40, leftMargin=40, topMargin=40, bottomMargin=60) styles = getSampleStyleSheet() # Custom styles styles.add(ParagraphStyle(name='CoverTitle', parent=styles['Title'], fontName='Helvetica-Bold', fontSize=36, spaceAfter=20, textColor=colors.HexColor("#0f172a"), alignment=1)) styles.add(ParagraphStyle(name='MissionBadge', parent=styles['Title'], fontName='Helvetica-Bold', fontSize=14, spaceAfter=20, textColor=colors.HexColor("#3b82f6"), alignment=1)) styles.add(ParagraphStyle(name='SectionHeader', parent=styles['Heading1'], fontName='Helvetica-Bold', fontSize=18, spaceBefore=20, spaceAfter=15, textColor=colors.HexColor("#0f172a"), borderPadding=4)) styles.add(ParagraphStyle(name='SubSection', parent=styles['Heading2'], fontName='Helvetica-Bold', fontSize=14, spaceBefore=10, spaceAfter=5, textColor=colors.HexColor("#1e293b"))) styles.add(ParagraphStyle(name='CustomBodyText', parent=styles['Normal'], fontName='Helvetica', fontSize=10, spaceAfter=8, leading=14)) styles.add(ParagraphStyle(name='VerdictText', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=22, alignment=1)) styles.add(ParagraphStyle(name='SummaryText', parent=styles['Normal'], fontName='Helvetica', fontSize=11, leading=15, spaceBefore=10, spaceAfter=10, textColor=colors.HexColor("#334155"))) elements = [] pli_data = analysis_data.get('pli', {}) pli_score = pli_data.get('score', 0) def fmt(val, dec=4): try: return f"{float(val):.{dec}f}" except (ValueError, TypeError): return str(val) verdict = "Rejected" verdict_color = colors.red if pli_score >= 85: verdict = "High-Priority Candidate" verdict_color = colors.HexColor("#10b981") # Emerald elif pli_score >= 70: verdict = "Strong Candidate" verdict_color = colors.HexColor("#3b82f6") # Blue elif pli_score >= 50: verdict = "Possible Candidate" verdict_color = colors.HexColor("#f59e0b") # Amber elif pli_score >= 30: verdict = "Review Required" verdict_color = colors.HexColor("#ef4444") # Red # PAGE 1: EXECUTIVE COVER elements.append(Spacer(1, 1.0*inch)) elements.append(Paragraph("EXONYX", styles['CoverTitle'])) elements.append(Paragraph("SCIENTIFIC DISCOVERY DOSSIER", styles['MissionBadge'])) elements.append(Spacer(1, 0.5*inch)) elements.append(HRFlowable(width="100%", thickness=3, color=colors.HexColor("#0f172a"), spaceBefore=10, spaceAfter=20)) cover_data = [ ["Target Identifier:", target_name], ["Mission Data Source:", mission], ["Analysis Timestamp:", analysis_data.get('analysis_date', datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"))], ["Planet Likelihood Index (PLI):", f"{pli_score:.1f} / 100"] ] t_cover = Table(cover_data, colWidths=[200, 250]) t_cover.setStyle(TableStyle([ ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), ('FONTNAME', (0,0), (0,-1), 'Helvetica-Bold'), ('ALIGN', (0,0), (0,-1), 'RIGHT'), ('ALIGN', (1,0), (1,-1), 'LEFT'), ('TEXTCOLOR', (1,3), (1,3), verdict_color), ('FONTSIZE', (1,3), (1,3), 16), ('FONTNAME', (1,3), (1,3), 'Helvetica-Bold'), ('BOTTOMPADDING', (0,0), (-1,-1), 10), ])) elements.append(t_cover) elements.append(Spacer(1, 0.5*inch)) elements.append(Paragraph(f'CLASSIFICATION: {verdict.upper()}', styles['VerdictText'])) elements.append(Spacer(1, 0.5*inch)) elements.append(HRFlowable(width="100%", thickness=3, color=colors.HexColor("#0f172a"), spaceBefore=20, spaceAfter=20)) elements.append(Paragraph("Verdict Transparency Breakdown", styles['SectionHeader'])) elements.append(Paragraph("The Planet Likelihood Index (PLI) is determined by the following weighted pipeline contributions:", styles['SummaryText'])) val_sum = analysis_data.get('validation_summary', {}) fp_res = analysis_data.get('false_positive', {}) meta = analysis_data.get('metadata', {}) # Real pipeline values tls_conf = val_sum.get('power_spectrum', {}).get('sde', 5.0) * 10 if val_sum.get('power_spectrum') else 50 if 'tls_confidence' in analysis_data.get('validation_summary', {}): tls_conf = val_sum['tls_confidence'] cnn_conf = val_sum.get('cnn_confidence', 50) sig_qual = meta.get('signal_quality', 50) consistency = meta.get('consistency', 80) # Placeholder if absent fp_risk = fp_res.get('risk', 50) fp_rejection = max(0, 100 - fp_risk) bar_img = plot_verdict_bars(tls_conf, cnn_conf, sig_qual, consistency, fp_rejection) elements.append(Image(bar_img, width=6*inch, height=3*inch)) elements.append(PageBreak()) # PAGE 2: SYSTEM PROFILE & PLANETARY VISUALIZER elements.append(Paragraph("System Profile", styles['SectionHeader'])) # Host Star Data elements.append(Paragraph("Host Star Information", styles['SubSection'])) # Filter N/A def robust_get(d, k, precision=2): val = d.get(k) if val is None or val == 'N/A' or str(val) == 'nan': return "Unknown" return fmt(val, precision) star_data_raw = [ ["Parameter", "Value", "Parameter", "Value"], ["Radius (R_Sun)", robust_get(meta, 'radius', 2), "Right Ascension", robust_get(meta, 'ra', 4)], ["Mass (M_Sun)", robust_get(meta, 'mass', 2), "Declination", robust_get(meta, 'dec', 4)], ["Eff. Temp (K)", robust_get(meta, 'teff', 0), "Obs. Span (d)", robust_get(meta, 'obs_span', 1)], ] t_star = Table(star_data_raw, colWidths=[120, 100, 120, 100]) t_star.setStyle(TableStyle([ ('BACKGROUND', (0,0), (-1,0), colors.HexColor("#1e293b")), ('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke), ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), ('GRID', (0,0), (-1,-1), 1, colors.HexColor("#cbd5e1")), ('BACKGROUND', (0,1), (0,-1), colors.HexColor("#f8fafc")), ('BACKGROUND', (2,1), (2,-1), colors.HexColor("#f8fafc")), ('FONTNAME', (0,1), (0,-1), 'Helvetica-Bold'), ('FONTNAME', (2,1), (2,-1), 'Helvetica-Bold'), ('PADDING', (0,0), (-1,-1), 6), ])) elements.append(t_star) elements.append(Spacer(1, 0.2*inch)) # Planet Data char_res = analysis_data.get('characterization', {}) hab_res = analysis_data.get('habitability', {}) elements.append(Paragraph("Candidate Profile", styles['SubSection'])) planet_data_raw = [ ["Parameter", "Value"], ["Orbital Period (Days)", f"{robust_get(char_res, 'period_days', 5)} ± {robust_get(char_res, 'period_err', 5)}"], ["Planet Radius (R_Earth)", f"{robust_get(char_res, 'planet_radius_earth', 2)} ± {robust_get(char_res, 'planet_radius_err', 2)}"], ["Semi-Major Axis (AU)", f"{robust_get(char_res, 'semi_major_axis_au', 4)} ± {robust_get(char_res, 'semi_major_axis_err', 4)}"], ["Transit Duration (Hours)", robust_get(char_res, 'transit_duration_hours', 2)], ["Equilibrium Temp (K)", f"{robust_get(hab_res, 'equilibrium_temperature_k', 1)} ± {robust_get(hab_res, 'equilibrium_temperature_err', 1)}"], ["Earth Similarity Index", robust_get(hab_res, 'esi', 2)], ] # Filter out completely unknown rows filtered_planet_data = [planet_data_raw[0]] for row in planet_data_raw[1:]: if not ("Unknown ± Unknown" in row[1] or row[1] == "Unknown"): filtered_planet_data.append(row) if len(filtered_planet_data) > 1: t_planet = Table(filtered_planet_data, colWidths=[200, 240]) t_planet.setStyle(TableStyle([ ('BACKGROUND', (0,0), (-1,0), colors.HexColor("#1e293b")), ('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke), ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), ('GRID', (0,0), (-1,-1), 1, colors.HexColor("#cbd5e1")), ('BACKGROUND', (0,1), (0,-1), colors.HexColor("#f8fafc")), ('FONTNAME', (0,1), (0,-1), 'Helvetica-Bold'), ('PADDING', (0,0), (-1,-1), 6), ])) elements.append(t_planet) else: elements.append(Paragraph("Candidate metrics are unavailable.", styles['CustomBodyText'])) elements.append(Spacer(1, 0.3*inch)) # Vis elements.append(Paragraph("Planetary System Visualizer", styles['SubSection'])) teff_val = meta.get('teff') teff_val = float(teff_val) if teff_val and teff_val != 'N/A' else None rad_val = meta.get('radius') rad_val = float(rad_val) if rad_val and rad_val != 'N/A' else None a_au_val = char_res.get('semi_major_axis_au') a_au_val = float(a_au_val) if a_au_val and a_au_val != 'N/A' else None pr_val = char_res.get('planet_radius_earth') pr_val = float(pr_val) if pr_val and pr_val != 'N/A' else None vis_img = plot_system_visualizer(a_au_val, teff_val, rad_val, pr_val) elements.append(Image(vis_img, width=5.5*inch, height=3.66*inch)) elements.append(PageBreak()) # ADAPTIVE RENDERING ts_data = analysis_data.get('data', {}) time = ts_data.get('time', []) # PAGE 3: LIGHT CURVES (Conditionally Rendered) if time and len(time) > 0: elements.append(Paragraph("Light Curve Analysis", styles['SectionHeader'])) raw_flux = ts_data.get('raw_flux', []) clean_flux = ts_data.get('clean_flux', []) raw_img = plot_light_curve(time, raw_flux, "Raw Photometric Data", color='#94a3b8') elements.append(Image(raw_img, width=6.5*inch, height=2.8*inch)) elements.append(Spacer(1, 0.2*inch)) clean_img = plot_light_curve(time, clean_flux, "Detrended Light Curve", color='#3b82f6', is_scatter=False) elements.append(Image(clean_img, width=6.5*inch, height=2.8*inch)) elements.append(PageBreak()) # PAGE 4: TLS EVIDENCE (Conditionally Rendered) power_spectrum = val_sum.get('power_spectrum', {}) if power_spectrum and isinstance(power_spectrum, dict): periods = power_spectrum.get('periods', []) power = power_spectrum.get('power', []) best_period = val_sum.get('period', 0) if periods and power and len(periods) > 0: elements.append(Paragraph("TLS Detection Evidence", styles['SectionHeader'])) tls_img = plot_tls_spectrum(periods, power, best_period) elements.append(Image(tls_img, width=6.5*inch, height=3.7*inch)) elements.append(Spacer(1, 0.2*inch)) elements.append(Paragraph("Detection Statistics", styles['SubSection'])) det_data = [ ["Metric", "Value"], ["Best Period", f"{fmt(best_period, 4)} d"], ["Peak Power", fmt(max(power), 2) if power else "N/A"], ["SDE Threshold (Est)", "7.00"], ["Detection Confidence", f"{fmt(tls_conf, 1)}%"] ] t_det = Table(det_data, colWidths=[200, 200]) t_det.setStyle(TableStyle([ ('BACKGROUND', (0,0), (-1,0), colors.HexColor("#1e293b")), ('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke), ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), ('GRID', (0,0), (-1,-1), 1, colors.HexColor("#cbd5e1")), ('PADDING', (0,0), (-1,-1), 6), ])) elements.append(t_det) elements.append(PageBreak()) # PAGE 5: BATMAN FIT (Conditionally Rendered) fit_data = analysis_data.get('fit') phase = ts_data.get('phase', []) clean_flux = ts_data.get('clean_flux', []) if fit_data and phase and len(phase) == len(clean_flux) and len(phase) > 0: elements.append(Paragraph("Transit Fit & Modeling", styles['SectionHeader'])) model_flux = fit_data.get('model_flux', []) residuals = fit_data.get('residuals', []) batman_img = plot_batman_fit(phase, clean_flux, model_flux, residuals) elements.append(Image(batman_img, width=6.5*inch, height=4.6*inch)) elements.append(Spacer(1, 0.2*inch)) elements.append(Paragraph("Fit Quality Metrics", styles['SubSection'])) fit_stats = [ ["Metric", "Value"], ["Impact Parameter (b)", robust_get(fit_data, 'impact_parameter', 3)], ["Radius Ratio (Rp/Rs)", robust_get(fit_data, 'rp_rs', 4)], ["a/Rs", robust_get(fit_data, 'a_rs', 2)], ["Chi-Square", robust_get(fit_data, 'chi_square', 2)], ["Reduced Chi-Square", robust_get(fit_data, 'reduced_chi_square', 3)], ["RMS", robust_get(fit_data, 'rms', 6)] ] t_fit = Table(fit_stats, colWidths=[200, 200]) t_fit.setStyle(TableStyle([ ('BACKGROUND', (0,0), (-1,0), colors.HexColor("#1e293b")), ('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke), ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), ('GRID', (0,0), (-1,-1), 1, colors.HexColor("#cbd5e1")), ('PADDING', (0,0), (-1,-1), 6), ])) elements.append(t_fit) elements.append(PageBreak()) # PAGE 6: APPENDICES & EXPANSION elements.append(Paragraph("Appendices", styles['SectionHeader'])) elements.append(Paragraph("Appendix A: False Positive Analysis", styles['SubSection'])) fp_table_data = [ ["Risk Factor", "Score/Status"], ["Overall False Positive Risk", f"{fmt(fp_risk, 1)}%"], ["CNN Model Prediction", val_sum.get('cnn_message', 'Analysis not available')], ["Risk Assessment", fp_res.get('summary', 'Analysis not available')] ] t_fp = Table(fp_table_data, colWidths=[200, 300]) t_fp.setStyle(TableStyle([ ('GRID', (0,0), (-1,-1), 1, colors.HexColor("#cbd5e1")), ('BACKGROUND', (0,0), (-1,0), colors.HexColor("#1e293b")), ('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke), ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), ('PADDING', (0,0), (-1,-1), 6), ])) elements.append(t_fp) # Future Expansion Loop (Conditionally Rendered) future_keys = [ ('mcmc', 'MCMC Posterior Diagnostics'), ('deep_recovery', 'Deep Recovery Pipeline'), ('follow_up', 'Follow-Up Observation Logs') ] appendix_counter = ord('B') for key, title in future_keys: if key in analysis_data and analysis_data[key]: elements.append(Spacer(1, 0.3*inch)) elements.append(Paragraph(f"Appendix {chr(appendix_counter)}: {title}", styles['SubSection'])) elements.append(Paragraph(str(analysis_data[key]), styles['CustomBodyText'])) appendix_counter += 1 # Add Footer Function def add_footer(canvas, doc): canvas.saveState() canvas.setFont('Helvetica', 9) canvas.setStrokeColor(colors.HexColor("#cbd5e1")) canvas.line(40, 40, letter[0]-40, 40) canvas.drawString(40, 25, "EXONYX Scientific Discovery Dossier") canvas.drawRightString(letter[0]-40, 25, f"Page {doc.page}") canvas.drawCentredString(letter[0]/2.0, 25, datetime.datetime.utcnow().strftime("%Y-%m-%d UTC")) canvas.restoreState() doc.build(elements, onFirstPage=add_footer, onLaterPages=add_footer) pdf_bytes = buffer.getvalue() buffer.close() return pdf_bytes