# ============================================================================= # conference_poll / app.py # Hi-Acts 3rd Network Meeting — Innovation Challenge Arena # Developed by TXproducts, June 2026 # # This is the main server file. It does three things: # 1. Defines all poll questions and answer options (edit these yourself) # 2. Handles answer submissions from attendees # 3. Serves all the pages of the app # # TO CHANGE QUESTIONS: edit the QUESTIONS list below (lines ~30-120) # TO START THE APP: run python3 app.py in the terminal # TO STOP THE APP: press Ctrl+C in the terminal # ============================================================================= from flask import Flask, render_template, request, jsonify, session, redirect, Response from collections import defaultdict, Counter import uuid import re import threading import time import random import json import os import csv import io import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase from email import encoders try: from fpdf import FPDF FPDF_AVAILABLE = True except ImportError: FPDF_AVAILABLE = False # Create the Flask app app = Flask(__name__) # Secret key used to secure browser sessions (keeps each attendee's answers separate) app.secret_key = 'hiacts-txproducts-2026' # ============================================================================= # QUESTIONS # This is the only section you need to edit to change the poll content. # # Each question needs: # 'id' — a unique number (do not change these) # 'text' — the question shown to attendees (edit freely) # 'type' — 'multiple_choice' for button answers, 'free_text' for typed answers # 'options' — the list of answer buttons (multiple_choice only) # always keep 'Other' as the last option if you want a free-text fallback # # After editing, save the file and restart the app to see your changes. # ============================================================================= QUESTIONS = [ { 'id': 1, 'label': 'Your Role', 'text': 'What best describes your role at the beamline?', 'type': 'multiple_choice', 'options': ['Beamline Scientist', 'Instrument Scientist', 'Industry User', 'Academic User', 'Control System / IT', 'Other'] }, { 'id': 2, 'label': 'Metadata Automation', 'text': 'Do you have automated routines to read and process metadata?', 'type': 'multiple_choice', 'options': ['Yes', 'Partially yes', 'No', 'Other'] }, { 'id': 3, 'label': 'In-Experiment Analysis', 'text': 'To what extent do you analyse experimental data during the experiment?', 'type': 'multiple_choice', 'options': ['Fully', 'Partially', 'Preliminary only', 'Not at all', 'Other'] }, { 'id': 4, 'label': 'On Standardisation', 'text': 'How do you feel about standardising beamline workflows?', 'type': 'multiple_choice', 'options': [ 'Necessary and overdue', 'Desirable but very difficult', 'Too rigid for our experimental needs', 'Not our problem to solve', 'Other' ] }, { 'id': 5, 'label': 'Script Adaptation', 'text': 'Before starting data analysis, do you have to adapt the existing scripts at the beamline?', 'type': 'multiple_choice', 'options': [ 'No, my scripts run out of the box', 'I make minor adaptations, e.g. selecting the ROI', 'I need to make significant adaptations', 'I have to rewrite scripts from scratch', 'Other' ] }, { 'id': 6, 'label': 'Methods Used', 'text': 'What methods do you primarily use?', 'type': 'multiple_choice', 'options': ['Mostly diffraction', 'Mostly spectroscopy', 'Other'] }, { 'id': 7, 'label': 'Time-Resolved', 'text': 'Are you using time-resolved, in-situ or in-operando methods?', 'type': 'multiple_choice', 'options': ['In-situ / in-operando', 'Microsecond dynamics', 'Picosecond / nanosecond dynamics', 'All of the above', 'None', 'Other'] }, { 'id': 8, 'label': 'Analysis Hardware', 'text': 'On what hardware do you analyse your data?', 'type': 'multiple_choice', 'options': ['DESY Maxwell Cluster (or similar)', 'Commercial cloud service', 'Own hardware', 'Other'] }, { 'id': 9, 'label': 'Efficiency Gain', 'text': 'To what extent do you think data standardisation could increase the efficiency of your experiment?', 'type': 'multiple_choice', 'options': ['No need for improvement', 'Minor efficiency increase (<20%)', 'Significant efficiency increase', 'Game changer', 'Other'] }, { 'id': 10, 'label': 'Electronic Labbook', 'text': 'Are you using an electronic labbook?', 'type': 'multiple_choice', 'options': ['Yes', 'No'] }, { 'id': 11, 'label': 'Labbook Connected', 'text': 'Is the labbook connected to your data analysis?', 'type': 'multiple_choice', 'options': ['Yes', 'No'] }, ] # ============================================================================= # DEMO DATA — used only for internal testing, not shown at the conference # These are 20 simulated participant responses for testing the display page. # Safe to ignore. # ============================================================================= SIMULATED_RESPONSES = [ # (Q1 role, Q2 metadata auto, Q3 analysis during, Q4 standardisation, Q5 script adapt, Q6 method, Q7 time-resolved, Q8 hardware, Q9 efficiency, Q10 labbook, Q11 connected) ("Beamline Scientist", "Partially yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "No"), ("Industry User", "No", "Not at all", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "None", "Own hardware", "Game changer", "No", "No"), ("Beamline Scientist", "Partially yes", "Partially", "Desirable but very difficult", "I need to make significant adaptations", "Mostly spectroscopy", "None", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "Yes"), ("Instrument Scientist", "No", "Preliminary only", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Game changer", "Yes", "No"), ("Academic User", "Yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly spectroscopy", "All of the above", "Own hardware", "Significant efficiency increase", "Yes", "Yes"), ("Control System / IT", "Yes", "Fully", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly diffraction", "None", "DESY Maxwell Cluster (or similar)", "Minor efficiency increase (<20%)", "No", "No"), ("Industry User", "No", "Not at all", "Too rigid for our experimental needs", "I have to rewrite scripts from scratch", "Mostly spectroscopy", "None", "Own hardware", "No need for improvement", "No", "No"), ("Beamline Scientist", "Partially yes", "Partially", "Necessary and overdue", "I need to make significant adaptations", "Mostly diffraction", "Microsecond dynamics", "DESY Maxwell Cluster (or similar)", "Game changer", "Yes", "No"), ("Academic User", "No", "Preliminary only", "Desirable but very difficult", "I have to rewrite scripts from scratch", "Mostly spectroscopy", "Picosecond / nanosecond dynamics", "Own hardware", "Significant efficiency increase", "No", "No"), ("Instrument Scientist", "Partially yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "Yes"), ("Industry User", "No", "Not at all", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "None", "Commercial cloud service", "Game changer", "No", "No"), ("Beamline Scientist", "Yes", "Fully", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly spectroscopy", "None", "DESY Maxwell Cluster (or similar)", "Minor efficiency increase (<20%)", "Yes", "Yes"), ("Academic User", "Partially yes", "Partially", "Necessary and overdue", "I need to make significant adaptations", "Mostly spectroscopy", "In-situ / in-operando", "Own hardware", "Significant efficiency increase", "Yes", "No"), ("Control System / IT", "Yes", "Partially", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly diffraction", "None", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "No", "No"), ("Instrument Scientist", "No", "Preliminary only", "Not our problem to solve", "I have to rewrite scripts from scratch", "Mostly diffraction", "Picosecond / nanosecond dynamics", "Own hardware", "No need for improvement", "No", "No"), ("Beamline Scientist", "Partially yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly spectroscopy", "All of the above", "DESY Maxwell Cluster (or similar)", "Game changer", "Yes", "Yes"), ("Academic User", "No", "Not at all", "Desirable but very difficult", "I have to rewrite scripts from scratch", "Mostly spectroscopy", "None", "Own hardware", "Significant efficiency increase", "No", "No"), ("Industry User", "No", "Not at all", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "None", "Commercial cloud service", "Game changer", "No", "No"), ("Beamline Scientist", "Yes", "Fully", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Minor efficiency increase (<20%)", "Yes", "No"), ("Instrument Scientist", "Partially yes", "Preliminary only", "Necessary and overdue", "I need to make significant adaptations", "Mostly spectroscopy", "Microsecond dynamics", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "No"), ] # Words excluded when processing free-text answers into display phrases STOPWORDS = { 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'it', 'is', 'are', 'was', 'be', 'this', 'that', 'i', 'my', 'we', 'our', 'you', 'your', 'they', 'their', 'have', 'has', 'had', 'do', 'does', 'did', 'not', 'no', 'if', 'so', 'as', 'by', 'from', 'would', 'could', 'should', 'all', 'more', 'when', 'then', 'than', 'can', 'will', 'its', 'into', 'just', 'also', 'very', 'data', 'without', 'across', 'one', 'any', 'work', 'now', 'first', } # ============================================================================= # DATA STORE — saved to a JSON file so answers survive restarts and wifi drops # File is written after every submission. Safe to lose power mid-session. # ============================================================================= DATA_FILE = os.path.join(os.path.dirname(__file__), 'answers.json') def load_answers(): if os.path.exists(DATA_FILE): try: with open(DATA_FILE, 'r') as f: raw = json.load(f) result = defaultdict(list) for k, v in raw.items(): result[int(k)] = v return result except Exception: pass return defaultdict(list) def save_answers(): with open(DATA_FILE, 'w') as f: json.dump({str(k): v for k, v in answers.items()}, f) answers = load_answers() # ============================================================================= # EMAIL STORE — collects emails from attendees who want the full analysis report # Stored separately from answers so it is never wiped by an admin reset. # ============================================================================= EMAILS_FILE = os.path.join(os.path.dirname(__file__), 'emails.json') # emails.json stores a list of dicts: {email, token, retained} # retained=True means the attendee clicked the keep link and must NOT be deleted automatically. def load_email_records(): if os.path.exists(EMAILS_FILE): try: with open(EMAILS_FILE, 'r') as f: data = json.load(f) # backward-compat: old format was a plain list of strings if data and isinstance(data[0], str): return [{'email': e, 'token': str(uuid.uuid4()), 'retained': False} for e in data] return data except Exception: pass return [] def load_emails(): return [r['email'] for r in load_email_records()] def store_email(email): records = load_email_records() if any(r['email'] == email for r in records): return None # already stored, return None so caller knows no token was generated token = str(uuid.uuid4()) records.append({'email': email, 'token': token, 'retained': False}) with open(EMAILS_FILE, 'w') as f: json.dump(records, f) return token def mark_retained(token): records = load_email_records() for r in records: if r['token'] == token: r['retained'] = True with open(EMAILS_FILE, 'w') as f: json.dump(records, f) return r['email'] return None COMMENTS_FILE = os.path.join(os.path.dirname(__file__), 'comments.json') def load_comments(): if os.path.exists(COMMENTS_FILE): try: with open(COMMENTS_FILE, 'r') as f: return json.load(f) except Exception: pass return [] def store_comment(text): existing = load_comments() existing.append(text) with open(COMMENTS_FILE, 'w') as f: json.dump(existing, f) _stream_state = {'running': False, 'progress': 0, 'total': 0} _session_state = {'active_question': 0} _session_version = [1] # incremented on reset to invalidate all existing sessions # ============================================================================= # SMTP CONFIG — set these as environment variables on your server. # Never hardcode credentials here; this file is in git. # # On PythonAnywhere: Web tab → scroll to "Environment variables" section # MAIL_HOST — your SMTP server (e.g. smtp.office365.com or smtp.gmail.com) # MAIL_PORT — usually 587 # MAIL_USER — the full sending address (e.g. mblack@txproducts.de) # MAIL_PASS — your email password or app password # ============================================================================= MAIL_HOST = os.environ.get('MAIL_HOST', '') MAIL_PORT = int(os.environ.get('MAIL_PORT', '587')) MAIL_USER = os.environ.get('MAIL_USER', '') MAIL_PASS = os.environ.get('MAIL_PASS', '') BASE_URL = os.environ.get('BASE_URL', 'http://localhost:5001') ADMIN_PASS = os.environ.get('ADMIN_PASS', 'hiacts2026') # set this in PythonAnywhere env vars def admin_required(f): """Decorator that redirects to /admin/login if the session is not authenticated.""" from functools import wraps @wraps(f) def decorated(*args, **kwargs): if not session.get('admin_ok'): return redirect('/admin/login') return f(*args, **kwargs) return decorated def get_respondents(snapshot): """Build per-respondent dicts using positional alignment across all questions.""" min_len = min((len(snapshot.get(q['id'], [])) for q in QUESTIONS), default=0) return [ {q['id']: snapshot[q['id']][i] for q in QUESTIONS if i < len(snapshot[q['id']])} for i in range(min_len) ] def pct(a, b): return int(round(a / b * 100)) if b > 0 else 0 def generate_narrative(respondents, role_counts, champions, pragmatists, skeptics, n_no_auto, n_script_heavy, n_believers): n = len(respondents) if n == 0: return 'No responses collected yet.' top_roles = [ f'{cnt} {role.split()[0]}{"s" if cnt > 1 else ""}' for role, cnt in role_counts.most_common(3) ] para = ( f'This report captures {n} responses from Hi-Acts participants at DESY Hamburg, ' f'including {", ".join(top_roles)} and others. ' f'The data reveals a community where manual effort remains the norm: ' f'{pct(n_no_auto, n)}% have no automated routines to read and process metadata, ' f'and {pct(n_script_heavy, n)}% must make significant adaptations or fully ' f'rewrite analysis scripts before they can work with beamline data. ' ) if champions > 0 or skeptics > 0: para += ( f'On standardisation, {pct(champions, n)}% call it necessary and overdue, ' f'while {pct(skeptics, n)}% express resistance. ' ) para += ( f'Despite this divide, {pct(n_believers, n)}% expect standardisation to deliver ' f'a significant efficiency gain or describe it as a game changer for their workflow.' ) return para def generate_analysis_pdf(): """Analytical PDF that combines questions to reveal pain chains, adoption segments, and role divergences — not available from the live poll display.""" if not FPDF_AVAILABLE: return None snapshot = {q['id']: list(answers[q['id']]) for q in QUESTIONS} respondents = get_respondents(snapshot) n = len(respondents) if n == 0: return None # ── Computed metrics ─────────────────────────────────────────── role_counts = Counter(r.get(1, 'Other') for r in respondents) CHAMP_ANS = 'Necessary and overdue' PRAG_ANS = 'Desirable but very difficult' SKEP_SET = {'Too rigid for our experimental needs', 'Not our problem to solve'} SCRIPT_HEAVY = {'I need to make significant adaptations', 'I have to rewrite scripts from scratch'} BELIEVERS = {'Significant efficiency increase', 'Game changer'} LIMITED_ANA = {'Preliminary only', 'Not at all'} champions = sum(1 for r in respondents if r.get(4) == CHAMP_ANS) pragmatists = sum(1 for r in respondents if r.get(4) == PRAG_ANS) skeptics = sum(1 for r in respondents if r.get(4) in SKEP_SET) n_no_auto = sum(1 for r in respondents if r.get(2) == 'No') n_script_heavy= sum(1 for r in respondents if r.get(5) in SCRIPT_HEAVY) n_believers = sum(1 for r in respondents if r.get(9) in BELIEVERS) n_limited_ana = sum(1 for r in respondents if r.get(3) in LIMITED_ANA) industry = [r for r in respondents if r.get(1) == 'Industry User'] academic = [r for r in respondents if r.get(1) == 'Academic User'] facility = [r for r in respondents if r.get(1) not in ('Industry User', 'Academic User')] n_ind, n_aca, n_fac = len(industry), len(academic), len(facility) role_seg = {} for r in respondents: role = r.get(1, 'Other') q4 = r.get(4, '') if role not in role_seg: role_seg[role] = {'champ': 0, 'prag': 0, 'skep': 0, 'total': 0} role_seg[role]['total'] += 1 if q4 == CHAMP_ANS: role_seg[role]['champ'] += 1 elif q4 == PRAG_ANS: role_seg[role]['prag'] += 1 elif q4 in SKEP_SET: role_seg[role]['skep'] += 1 # Adoption segment lists (for cross-tab analysis) champ_list = [r for r in respondents if r.get(4) == CHAMP_ANS] prag_list = [r for r in respondents if r.get(4) == PRAG_ANS] skep_list = [r for r in respondents if r.get(4) in SKEP_SET] n_champ, n_prag, n_skep = len(champ_list), len(prag_list), len(skep_list) # Frustrated Champions: want standardisation but have no automation n_frustrated = sum(1 for r in champ_list if r.get(2) == 'No') frustrated_roles = Counter(r.get(1, 'Other') for r in champ_list if r.get(2) == 'No') # Digital maturity score: Q2(0-2) + Q10(0-1) + Q11(0-1) = max 4 def mat_score(r): s = 2 if r.get(2) == 'Yes' else (1 if r.get(2) == 'Partially yes' else 0) s += 1 if r.get(10) == 'Yes' else 0 s += 1 if r.get(11) == 'Yes' else 0 return s role_maturity = {} for r in respondents: role_maturity.setdefault(r.get(1, 'Other'), []).append(mat_score(r)) # Time-resolved users vs those using no advanced methods complex_users = [r for r in respondents if r.get(7) not in ('None', 'Other', '', None)] simple_users = [r for r in respondents if r.get(7) == 'None'] n_cx, n_sx = len(complex_users), len(simple_users) # ── Colours ──────────────────────────────────────────────────── DARK = (15, 39, 68) MID = (26, 74, 138) LIGHT = (93, 184, 245) GOLD = (180, 134, 11) GREEN = (5, 150, 105) ORANGE = (217, 119, 6) RED = (220, 38, 38) GREY = (240, 244, 248) Q_CLR = [(37,99,235),(5,150,105),(217,119,6),(124,58,237)] pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=18) def draw_row(label, p_val, color, lx=12, lw=64, bx=78, bw=95, rh=6.5): """One labelled bar row: label | track+fill | pct. Advances cursor.""" y = pdf.get_y() pdf.set_xy(lx, y) pdf.set_font('Helvetica', '', 8) pdf.set_text_color(50, 60, 80) pdf.cell(lw, rh, label[:44]) pdf.set_fill_color(*GREY) pdf.rect(bx, y + rh * 0.22, bw, rh * 0.56, 'F') if p_val > 0: pdf.set_fill_color(*color) pdf.rect(bx, y + rh * 0.22, bw * p_val / 100, rh * 0.56, 'F') pdf.set_xy(bx + bw + 3, y) pdf.set_font('Helvetica', 'B', 8) pdf.set_text_color(*DARK) pdf.cell(16, rh, f'{p_val}%') pdf.set_y(y + rh + 0.5) def sec(title, color=None): """Dark coloured section banner.""" c = color or MID pdf.set_fill_color(*c) pdf.set_text_color(255, 255, 255) pdf.set_font('Helvetica', 'B', 9) pdf.cell(0, 7, f' {title}', fill=True, ln=True) pdf.ln(3) def subhead(label): pdf.set_font('Helvetica', 'B', 8) pdf.set_text_color(*MID) pdf.set_x(12) pdf.cell(0, 5.5, label, ln=True) # ═══════════════════════════════════════════════════════════════ # PAGE 1: COVER + NARRATIVE # ═══════════════════════════════════════════════════════════════ pdf.add_page() pdf.set_fill_color(*DARK) pdf.rect(0, 0, 210, 56, 'F') logo_path = os.path.join(os.path.dirname(__file__), 'static', 'logo_white.png') if os.path.exists(logo_path): pdf.image(logo_path, x=162, y=6, w=38) pdf.set_xy(0, 9) pdf.set_font('Helvetica', 'B', 8) pdf.set_text_color(*GOLD) pdf.cell(210, 5, 'HI-ACTS 3RD NETWORK MEETING DESY HAMBURG 10 JUNE 2026', align='C', ln=True) pdf.ln(2) pdf.set_font('Helvetica', 'B', 23) pdf.set_text_color(255, 255, 255) pdf.cell(210, 11, 'Innovation Challenge Arena', align='C', ln=True) pdf.set_font('Helvetica', '', 13) pdf.set_text_color(*LIGHT) pdf.cell(210, 7, 'In-Depth Analysis Report', align='C', ln=True) pdf.set_xy(0, 60) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(80, 90, 110) rs = ' / '.join( f'{cnt} {role.split()[0]}{"s" if cnt>1 else ""}' for role, cnt in role_counts.most_common(4) ) pdf.cell(210, 7, f'{n} respondents: {rs}', align='C', ln=True) pdf.ln(6) sec('The Story in the Data') narrative = generate_narrative(respondents, role_counts, champions, pragmatists, skeptics, n_no_auto, n_script_heavy, n_believers) pdf.set_font('Helvetica', '', 10) pdf.set_text_color(30, 40, 60) pdf.set_x(12) pdf.multi_cell(186, 6.5, narrative) pdf.ln(7) # Three headline stat boxes for i, (num, lbl) in enumerate([ (f'{pct(n_no_auto, n)}%', 'have no metadata automation'), (f'{pct(n_script_heavy, n)}%', 'need major script rework'), (f'{pct(n_believers, n)}%', 'expect significant gain'), ]): bx = 12 + i * 64 by = pdf.get_y() pdf.set_fill_color(*DARK) pdf.rect(bx, by, 60, 22, 'F') pdf.set_xy(bx, by + 2) pdf.set_font('Helvetica', 'B', 20) pdf.set_text_color(*LIGHT) pdf.cell(60, 10, num, align='C', ln=True) pdf.set_xy(bx, pdf.get_y()) pdf.set_font('Helvetica', '', 7) pdf.set_text_color(180, 200, 220) pdf.cell(60, 6, lbl, align='C', ln=True) pdf.ln(6) # Role adoption table — lives on page 1, supporting the narrative directly sec('Adoption by Role: Champions, Pragmatists and Skeptics', MID) CX = [12, 84, 120, 158] CW = [70, 33, 33, 33] pdf.set_font('Helvetica', 'B', 8) for i, h in enumerate(['Role', 'Champions', 'Pragmatists', 'Skeptics']): pdf.set_xy(CX[i], pdf.get_y()) pdf.set_text_color(*([DARK, GREEN, MID, RED][i])) pdf.cell(CW[i], 5.5, h) pdf.ln(6) for role, _ in role_counts.most_common(): seg = role_seg.get(role, {'champ': 0, 'prag': 0, 'skep': 0, 'total': 0}) t = seg['total'] for i, (txt, clr) in enumerate([ (role[:30], DARK), (f'{pct(seg["champ"],t)}% ({seg["champ"]})', GREEN), (f'{pct(seg["prag"],t)}% ({seg["prag"]})', MID), (f'{pct(seg["skep"],t)}% ({seg["skep"]})', RED), ]): pdf.set_xy(CX[i], pdf.get_y()) pdf.set_font('Helvetica', 'B' if i == 0 else '', 8) pdf.set_text_color(*clr) pdf.cell(CW[i], 6, txt) pdf.ln(7) # ═══════════════════════════════════════════════════════════════ # PAGE 2: THE PAIN CHAIN # ═══════════════════════════════════════════════════════════════ pdf.add_page() sec('The Pain Chain: Automation Gaps to Limited In-Experiment Analysis', DARK) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Q2, Q5, and Q3 form a connected chain: no metadata automation forces manual ' 'script rework, which pushes analysis out of the experiment window entirely. ' 'Comparing industry users, academic users, and facility staff reveals who bears ' 'the greatest cost from this fragmentation.') pdf.ln(6) CHAIN_ITEMS = [ ('Q2: No automated metadata routines', n_no_auto, sum(1 for r in industry if r.get(2) == 'No'), sum(1 for r in academic if r.get(2) == 'No'), sum(1 for r in facility if r.get(2) == 'No')), ('Q5: Significant rework or full script rewrite', n_script_heavy, sum(1 for r in industry if r.get(5) in SCRIPT_HEAVY), sum(1 for r in academic if r.get(5) in SCRIPT_HEAVY), sum(1 for r in facility if r.get(5) in SCRIPT_HEAVY)), ('Q3: Preliminary or no analysis during experiment', n_limited_ana, sum(1 for r in industry if r.get(3) in LIMITED_ANA), sum(1 for r in academic if r.get(3) in LIMITED_ANA), sum(1 for r in facility if r.get(3) in LIMITED_ANA)), ] for grp_label, grp_n, grp_color, val_idx in [ (f'All respondents (n={n})', n, RED, 0), (f'Industry Users (n={n_ind})', n_ind, ORANGE, 1), (f'Academic Users (n={n_aca})', n_aca, MID, 2), (f'Facility Staff (n={n_fac})', n_fac, GREEN, 3), ]: if grp_n == 0: continue subhead(grp_label) for item in CHAIN_ITEMS: lbl, overall, ind_c, aca_c, fac_c = item cnt = [overall, ind_c, aca_c, fac_c][val_idx] draw_row(lbl, pct(cnt, grp_n), grp_color) pdf.ln(5) # ═══════════════════════════════════════════════════════════════ # PAGE 3: ADOPTION LANDSCAPE + EFFICIENCY BELIEF BY SEGMENT # ═══════════════════════════════════════════════════════════════ pdf.add_page() sec('Adoption Landscape and Efficiency Expectations (Q4 + Q9)', DARK) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Q4 maps attitudes toward standardisation into Champions, Pragmatists, and ' 'Skeptics. Crossing this with Q9 (expected efficiency gain) reveals whether ' 'attitude aligns with expectation: do Champions expect the biggest gains, and ' 'do Skeptics see no value? Divergence between attitude and expectation signals ' 'where communication rather than product development is the gap.') pdf.ln(5) subhead(f'Overall adoption split (n={n})') for lbl, cnt, color in [ ('Champions "Necessary and overdue"', champions, GREEN), ('Pragmatists "Desirable but very difficult"', pragmatists, MID), ('Skeptics "Too rigid / Not our problem"', skeptics, RED), ]: draw_row(f'{lbl} ({cnt})', pct(cnt, n), color) pdf.ln(5) q9_order = ['No need for improvement', 'Minor efficiency increase (<20%)', 'Significant efficiency increase', 'Game changer'] sec('Q9: Efficiency belief for each adoption segment', MID) for seg_lbl, seg_list, seg_n, seg_color in [ (f'Champions (n={n_champ})', champ_list, n_champ, GREEN), (f'Pragmatists (n={n_prag})', prag_list, n_prag, MID), (f'Skeptics (n={n_skep})', skep_list, n_skep, RED), ]: if seg_n == 0: continue subhead(seg_lbl) q9c = Counter(r.get(9, '') for r in seg_list if r.get(9)) for ans in q9_order: draw_row(ans[:44], pct(q9c.get(ans, 0), seg_n), seg_color) pdf.ln(4) # ═══════════════════════════════════════════════════════════════ # PAGE 4: ADOPTION-REALITY GAP + FRUSTRATED CHAMPIONS # ═══════════════════════════════════════════════════════════════ pdf.add_page() sec('Adoption-Reality Gap: What People Want vs What They Have (Q4 x Q2)', DARK) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Do those who want standardisation already have automation in place, or are ' 'they the ones in most pain? Crossing Q4 (attitude) with Q2 (automation level) ' 'answers this. A Champion with no automation is a "Frustrated Champion": the ' 'clearest signal of unmet demand. A Skeptic who already has automation may ' 'resist because their pain has already been solved individually.') pdf.ln(5) for seg_lbl, seg_list, seg_n, seg_color in [ (f'Champions (n={n_champ}) -- automation level (Q2)', champ_list, n_champ, GREEN), (f'Pragmatists (n={n_prag}) -- automation level (Q2)', prag_list, n_prag, MID), (f'Skeptics (n={n_skep}) -- automation level (Q2)', skep_list, n_skep, RED), ]: if seg_n == 0: continue subhead(seg_lbl) q2c = Counter(r.get(2, '') for r in seg_list if r.get(2)) for ans, color in [('Yes', GREEN), ('Partially yes', ORANGE), ('No', RED)]: draw_row(ans, pct(q2c.get(ans, 0), seg_n), color) pdf.ln(4) sec(f'Frustrated Champions: want standardisation, have no automation', MID) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, f'{n_frustrated} of {n_champ} Champions ({pct(n_frustrated, n_champ)}%) ' f'have Q4="Necessary and overdue" but Q2="No automation". ' f'These respondents represent the highest-priority target audience: ' f'motivated, in pain, and ready for a solution.') pdf.ln(4) if frustrated_roles: subhead('Frustrated Champions by role') for role, cnt in frustrated_roles.most_common(): total_in_role = role_counts.get(role, 1) draw_row(f'{role[:35]} ({cnt} of {total_in_role})', pct(cnt, total_in_role), GREEN) pdf.ln(5) sec('Skeptic Profile: do Skeptics have automation already? (Q4 x Q2)', RED) if n_skep > 0: skep_auto = Counter(r.get(2, '') for r in skep_list if r.get(2)) subhead(f'Automation level among Skeptics (n={n_skep})') for ans, color in [('Yes', GREEN), ('Partially yes', ORANGE), ('No', RED)]: draw_row(ans, pct(skep_auto.get(ans, 0), n_skep), color) pdf.ln(4) skep_q9 = Counter(r.get(9, '') for r in skep_list if r.get(9)) subhead(f'Efficiency belief among Skeptics (Q9)') for ans in q9_order: draw_row(ans[:44], pct(skep_q9.get(ans, 0), n_skep), RED) # ═══════════════════════════════════════════════════════════════ # PAGE 5: DIGITAL MATURITY COMPOSITE SCORE # ═══════════════════════════════════════════════════════════════ pdf.add_page() sec('Digital Maturity Score by Role (Q2 + Q10 + Q11)', DARK) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'A composite Digital Maturity Score combines three signals: automated metadata ' 'routines (Q2: 2pts=Yes, 1pt=Partially, 0=No), electronic labbook use (Q10: ' '1pt=Yes), and labbook connected to analysis (Q11: 1pt=Yes). Maximum score is ' '4. Roles scoring 0-1 are essentially analog; 2-3 are transitioning; 4 are ' 'fully digitally integrated. This reveals where standardisation adoption is ' 'most and least likely to happen quickly.') pdf.ln(5) subhead('Average Digital Maturity Score by role (max = 4)') role_avg = {role: sum(scores)/len(scores) for role, scores in role_maturity.items() if scores} for role, _ in role_counts.most_common(): avg = role_avg.get(role, 0) bar_pct = int(round(avg / 4 * 100)) color = GREEN if avg >= 3 else (ORANGE if avg >= 2 else RED) draw_row(f'{role[:32]} (avg {avg:.1f}/4)', bar_pct, color) pdf.ln(5) sec('Labbook Adoption by Role (Q10 + Q11)', MID) subhead('Labbook users per role (Q10=Yes)') for role, _ in role_counts.most_common(): role_rs = [r for r in respondents if r.get(1) == role] lb_count = sum(1 for r in role_rs if r.get(10) == 'Yes') draw_row(f'{role[:38]} ({lb_count}/{len(role_rs)})', pct(lb_count, len(role_rs)), GREEN) pdf.ln(4) lb_users = [r for r in respondents if r.get(10) == 'Yes'] if lb_users: conn_yes = sum(1 for r in lb_users if r.get(11) == 'Yes') subhead(f'Of {len(lb_users)} labbook users, connected to data analysis (Q11=Yes)') draw_row(f'Connected ({conn_yes})', pct(conn_yes, len(lb_users)), GREEN) draw_row(f'Not connected ({len(lb_users)-conn_yes})', pct(len(lb_users)-conn_yes, len(lb_users)), ORANGE) pdf.ln(5) sec('Script Adaptation Burden by Labbook Status (Q5 x Q10)', MID) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Do those with a digital labbook also adapt scripts more efficiently? ' 'If labbook users still rewrite scripts at the same rate, the labbook is not ' 'yet integrated into the full analytical workflow.') pdf.ln(4) no_lb = [r for r in respondents if r.get(10) == 'No'] for grp_lbl, grp_list, grp_color in [ (f'With labbook (n={len(lb_users)})', lb_users, GREEN), (f'Without labbook (n={len(no_lb)})', no_lb, RED), ]: if not grp_list: continue subhead(f'{grp_lbl} -- script adaptation (Q5)') q5c = Counter(r.get(5, '') for r in grp_list if r.get(5)) heavy = q5c.get('I need to make significant adaptations', 0) + \ q5c.get('I have to rewrite scripts from scratch', 0) draw_row('Major adaptation or full rewrite', pct(heavy, len(grp_list)), grp_color) easy = q5c.get('No, my scripts run out of the box', 0) + \ q5c.get('I make minor adaptations, e.g. selecting the ROI', 0) draw_row('Out of the box or minor adaption', pct(easy, len(grp_list)), GREEN if grp_color == GREEN else MID) pdf.ln(3) # ═══════════════════════════════════════════════════════════════ # PAGE 6: METHOD COMPLEXITY CROSS-ANALYSIS # ═══════════════════════════════════════════════════════════════ pdf.add_page() sec('Method Complexity vs Pain and Adoption (Q6 + Q7 cross-analysis)', DARK) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Users running time-resolved, in-situ, or in-operando experiments (Q7) face ' 'greater technical complexity. This page tests whether that complexity translates ' 'into greater pain (more script rework, less automation) and stronger demand for ' 'standardisation, or whether complex users have built their own solutions.') pdf.ln(5) for grp_lbl, grp_list, grp_color in [ (f'Time-resolved / in-situ users (n={n_cx})', complex_users, MID), (f'No advanced timing methods (n={n_sx})', simple_users, ORANGE), ]: if not grp_list: continue grp_n = len(grp_list) subhead(grp_lbl) no_auto_g = sum(1 for r in grp_list if r.get(2) == 'No') script_g = sum(1 for r in grp_list if r.get(5) in SCRIPT_HEAVY) champ_g = sum(1 for r in grp_list if r.get(4) == CHAMP_ANS) believer_g = sum(1 for r in grp_list if r.get(9) in BELIEVERS) draw_row('No metadata automation (Q2=No)', pct(no_auto_g, grp_n), grp_color) draw_row('Script rework/rewrite needed (Q5)', pct(script_g, grp_n), grp_color) draw_row('Champion for standardisation (Q4)', pct(champ_g, grp_n), GREEN) draw_row('Expects significant gain (Q9)', pct(believer_g,grp_n), GREEN) pdf.ln(4) sec('Method Type vs Adoption Attitude (Q6 x Q4)', MID) for method in ['Mostly diffraction', 'Mostly spectroscopy']: m_list = [r for r in respondents if r.get(6) == method] if not m_list: continue m_n = len(m_list) subhead(f'{method} users (n={m_n})') m_champ = sum(1 for r in m_list if r.get(4) == CHAMP_ANS) m_skep = sum(1 for r in m_list if r.get(4) in SKEP_SET) m_auto = sum(1 for r in m_list if r.get(2) == 'No') draw_row('Champions for standardisation', pct(m_champ, m_n), GREEN) draw_row('Skeptics', pct(m_skep, m_n), RED) draw_row('No metadata automation (Q2=No)', pct(m_auto, m_n), ORANGE) pdf.ln(4) sec('Q7 Methods vs Hardware Used (Q7 x Q8)', MID) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Do time-resolved users rely on high-performance cluster computing while ' 'simpler users analyse on their own hardware? If so, standardisation of ' 'data formats must accommodate HPC pipelines.') pdf.ln(4) for grp_lbl, grp_list in [ (f'Time-resolved users (n={n_cx})', complex_users), (f'No advanced timing (n={n_sx})', simple_users), ]: if not grp_list: continue grp_n = len(grp_list) subhead(grp_lbl) q8c = Counter(r.get(8, '') for r in grp_list if r.get(8)) for i, (hw, cnt) in enumerate(q8c.most_common()): draw_row(hw[:44], pct(cnt, grp_n), Q_CLR[i % len(Q_CLR)]) pdf.ln(3) # ═══════════════════════════════════════════════════════════════ # PAGE 7: EFFICIENCY BELIEF DEEP DIVE + HARDWARE OVERVIEW # ═══════════════════════════════════════════════════════════════ pdf.add_page() sec('Efficiency Belief Deep Dive: What Drives the "Game Changer" Response? (Q9)', DARK) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Q9 asks how much efficiency gain respondents expect from standardisation. ' 'Cross-referencing with pain indicators (Q2, Q5, Q3) reveals whether high ' 'expectations come from those in most pain, or whether the belief is ' 'independent of current pain level. This is the key validation test: ' 'does lived pain predict perceived solution value?') pdf.ln(5) believers_list = [r for r in respondents if r.get(9) in BELIEVERS] low_belief_list = [r for r in respondents if r.get(9) in {'No need for improvement', 'Minor efficiency increase (<20%)'}] n_bel = len(believers_list) n_low = len(low_belief_list) for grp_lbl, grp_list, grp_color in [ (f'High-belief: significant/game-changer (n={n_bel})', believers_list, GREEN), (f'Low-belief: no need / minor gain (n={n_low})', low_belief_list, RED), ]: if not grp_list: continue grp_n = len(grp_list) subhead(grp_lbl) no_a = sum(1 for r in grp_list if r.get(2) == 'No') scr = sum(1 for r in grp_list if r.get(5) in SCRIPT_HEAVY) lim = sum(1 for r in grp_list if r.get(3) in LIMITED_ANA) draw_row('No metadata automation (Q2=No)', pct(no_a, grp_n), grp_color) draw_row('Major script rework needed (Q5)', pct(scr, grp_n), grp_color) draw_row('Limited in-experiment analysis (Q3)', pct(lim, grp_n), grp_color) pdf.ln(4) sec('Efficiency Belief by Role (Q9 x Q1)', MID) pdf.set_font('Helvetica', '', 9) pdf.set_text_color(50, 60, 80) pdf.set_x(12) pdf.multi_cell(186, 5.5, 'Which roles are most convinced that standardisation will deliver? ' 'This identifies where the strongest internal champions are likely to be found.') pdf.ln(4) for role, _ in role_counts.most_common(): role_rs = [r for r in respondents if r.get(1) == role] role_n = len(role_rs) bel_cnt = sum(1 for r in role_rs if r.get(9) in BELIEVERS) color = GREEN if pct(bel_cnt, role_n) >= 60 else (ORANGE if pct(bel_cnt, role_n) >= 35 else RED) draw_row(f'{role[:36]} (n={role_n})', pct(bel_cnt, role_n), color) pdf.ln(5) sec('Analysis Hardware Overview (Q8)', MID) q8_all = Counter(r.get(8, '') for r in respondents if r.get(8)) for i, (ans, cnt) in enumerate(q8_all.most_common()): draw_row(ans[:44], pct(cnt, n), Q_CLR[i % len(Q_CLR)]) pdf.ln(4) subhead('Efficiency belief by hardware (Q8 x Q9: % expecting significant gain)') for hw, _ in q8_all.most_common(): hw_list = [r for r in respondents if r.get(8) == hw] hw_n = len(hw_list) bel = sum(1 for r in hw_list if r.get(9) in BELIEVERS) draw_row(hw[:44], pct(bel, hw_n), MID) pdf.ln(5) pdf.set_font('Helvetica', 'I', 7) pdf.set_text_color(160, 170, 185) pdf.cell(0, 5, 'Generated automatically from live poll data. Developed by TXproducts | mblack@txproducts.de', align='C', ln=True) return bytes(pdf.output()) def send_confirmation_email(to_email, token): """Sends an immediate data-retention notice with a personalised keep-link. Runs in a background thread.""" if not MAIL_HOST or not MAIL_USER or not MAIL_PASS: print(f'[email] SMTP not configured, skipping confirmation to {to_email}') return try: keep_url = f'{BASE_URL}/keep/{token}' msg = MIMEMultipart() msg['From'] = f'HiActs Poll - TXproducts <{MAIL_USER}>' msg['To'] = to_email msg['Subject'] = 'Hi-Acts Poll: your data and your full report' body_html = f"""
Hi-Acts 3rd Network Meeting
We have received your email address and will send you the full Innovation Challenge Arena analysis report after the event.
Your data and your rights (GDPR)
Your email address is held by TXproducts UG solely for the purpose of sending you this report. It will not be shared with or sold to any third party. It will be permanently deleted by 10 July 2026 unless you choose to keep it on file.
Would you like TXproducts to keep your contact details for future updates and communications? If yes, click the button below. If you do nothing, your email will be deleted by 10 July 2026 as stated.
Or ignore this email and your data will be deleted automatically.
To request immediate deletion or to exercise any of your rights under GDPR, contact us at mblack@txproducts.de.
TXproducts UG | mblack@txproducts.de
Data processed under GDPR Article 6(1)(a) on the basis of your consent.
Hi-Acts 3rd Network Meeting
This is to confirm that your email address has been permanently deleted from the Hi-Acts Innovation Challenge Arena poll system operated by TXproducts UG.
Deletion confirmed
Your email address is no longer stored in any TXproducts system related to this event. No further emails will be sent to this address from the Hi-Acts poll.
If you have any questions or concerns, contact us at mblack@txproducts.de.
TXproducts UG | mblack@txproducts.de
This deletion was carried out in accordance with GDPR Article 17 (Right to Erasure).
Hi-Acts 3rd Network Meeting
Thank you for taking part in the Innovation Challenge Arena poll at DESY Hamburg.
The full analysis report is attached as a PDF. It includes:
Developed by TXproducts | mblack@txproducts.de
Your contact details will be kept on file by TXproducts UG.
You will not receive a deletion notice.
To change your mind at any time, email mblack@txproducts.de.
This link has already been used or is no longer valid.
''', 404 # Sends the full PDF analysis report to every collected email address (run this after the conference) @app.route('/admin/login', methods=['GET', 'POST']) def admin_login(): error = '' if request.method == 'POST': if request.form.get('password') == ADMIN_PASS: session['admin_ok'] = True return redirect('/admin') error = 'Incorrect password.' return f'''Hi-Acts Innovation Challenge Arena