Flask poll app for the Hi-Acts 3rd Network Meeting at DESY Hamburg, 10 June 2026. Includes 11-question poll, live D3 bubble chart display, 7-page PDF analysis report, admin dashboard, and CSV export. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1201 lines
56 KiB
Python
1201 lines
56 KiB
Python
# =============================================================================
|
|
# 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')
|
|
|
|
def load_emails():
|
|
if os.path.exists(EMAILS_FILE):
|
|
try:
|
|
with open(EMAILS_FILE, 'r') as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
pass
|
|
return []
|
|
|
|
def store_email(email):
|
|
existing = load_emails()
|
|
if email not in existing:
|
|
existing.append(email)
|
|
with open(EMAILS_FILE, 'w') as f:
|
|
json.dump(existing, f)
|
|
|
|
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', '')
|
|
|
|
|
|
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_analysis_report(to_email):
|
|
"""Generates the PDF and emails it to the given address. Runs in a background thread."""
|
|
if not MAIL_HOST or not MAIL_USER or not MAIL_PASS:
|
|
print(f'[email] SMTP not configured — cannot send to {to_email}')
|
|
return
|
|
|
|
try:
|
|
pdf_bytes = generate_analysis_pdf()
|
|
|
|
msg = MIMEMultipart()
|
|
msg['From'] = f'HiActs Poll - TXproducts <{MAIL_USER}>'
|
|
msg['To'] = to_email
|
|
msg['Subject'] = 'Hi-Acts Network Meeting - Full Poll Analysis Report'
|
|
|
|
body_html = f"""
|
|
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#2d3748">
|
|
<div style="background:linear-gradient(135deg,#0f2744,#1a4a8a);padding:28px 32px;border-radius:10px 10px 0 0">
|
|
<p style="color:#f5c842;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;margin:0 0 8px">Hi-Acts 3rd Network Meeting</p>
|
|
<h1 style="color:white;font-size:22px;margin:0;line-height:1.3">Your Poll Analysis Report</h1>
|
|
</div>
|
|
<div style="background:#f8fafc;padding:28px 32px;border:1px solid #e2e8f0;border-radius:0 0 10px 10px">
|
|
<p>Thank you for taking part in the Innovation Challenge Arena poll at DESY Hamburg.</p>
|
|
<p>The full analysis report is attached as a PDF. It includes:</p>
|
|
<ul style="color:#4a5568;line-height:1.9">
|
|
<li>Key findings from all respondents</li>
|
|
<li>Top 5 answers per question with response counts</li>
|
|
<li>Cross-role breakdown — how different roles answered key questions differently</li>
|
|
</ul>
|
|
<p style="color:#8a9baf;font-size:12px;margin-top:24px">Developed by TXproducts | mblack@txproducts.de</p>
|
|
</div>
|
|
</div>
|
|
"""
|
|
msg.attach(MIMEText(body_html, 'html'))
|
|
|
|
if pdf_bytes:
|
|
part = MIMEBase('application', 'octet-stream')
|
|
part.set_payload(pdf_bytes)
|
|
encoders.encode_base64(part)
|
|
part.add_header('Content-Disposition', 'attachment', filename='HiActs_Analysis_Report.pdf')
|
|
msg.attach(part)
|
|
|
|
with smtplib.SMTP(MAIL_HOST, MAIL_PORT, timeout=15) as server:
|
|
server.ehlo()
|
|
server.starttls()
|
|
server.login(MAIL_USER, MAIL_PASS)
|
|
server.send_message(msg)
|
|
|
|
print(f'[email] Report sent to {to_email}')
|
|
|
|
except Exception as e:
|
|
print(f'[email] Failed to send to {to_email}: {e}')
|
|
|
|
|
|
# =============================================================================
|
|
# ROUTES — each function below handles one page or API endpoint
|
|
# =============================================================================
|
|
|
|
# Home page — the attendee poll
|
|
@app.route('/')
|
|
def index():
|
|
# Assign a unique session ID to each visitor so they can only answer once per question
|
|
if 'session_id' not in session or session.get('version') != _session_version[0]:
|
|
session['session_id'] = str(uuid.uuid4())
|
|
session['answered'] = []
|
|
session['version'] = _session_version[0]
|
|
return render_template('poll.html', questions=QUESTIONS)
|
|
|
|
|
|
# Receives a submitted answer from an attendee
|
|
@app.route('/submit', methods=['POST'])
|
|
def submit():
|
|
data = request.json
|
|
q_id = data.get('question_id')
|
|
answer = data.get('answer', '').strip()
|
|
|
|
if not answer or not q_id:
|
|
return jsonify({'error': 'Invalid'}), 400
|
|
|
|
# Reset session if the data was cleared since this session started
|
|
if session.get('version') != _session_version[0]:
|
|
session['answered'] = []
|
|
session['version'] = _session_version[0]
|
|
|
|
# Only record the answer if this session has not already answered this question
|
|
answered = session.get('answered', [])
|
|
if q_id not in answered:
|
|
answers[q_id].append(answer)
|
|
answered = answered + [q_id]
|
|
session['answered'] = answered
|
|
save_answers()
|
|
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# Receives an optional free-text comment from the attendee after all questions are answered
|
|
@app.route('/submit_comment', methods=['POST'])
|
|
def submit_comment():
|
|
data = request.json
|
|
text = data.get('comment', '').strip()[:150]
|
|
if text:
|
|
store_comment(text)
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# API endpoint — returns all current results as JSON for the display and results pages
|
|
@app.route('/api/results')
|
|
def results():
|
|
bubbles = []
|
|
|
|
for q in QUESTIONS:
|
|
q_id = q['id']
|
|
q_answers = answers[q_id]
|
|
|
|
if q['type'] == 'multiple_choice':
|
|
# Count how many times each option was chosen
|
|
counts = Counter(q_answers)
|
|
for text, count in counts.items():
|
|
bubbles.append({'text': text, 'value': count, 'question': q['text'], 'q_id': q_id})
|
|
else:
|
|
# For free-text answers, group by the first 7 words of each response
|
|
phrase_counts = Counter()
|
|
for a in q_answers:
|
|
phrase = ' '.join(a.strip().split()[:7])
|
|
if phrase:
|
|
phrase_counts[phrase] += 1
|
|
for phrase, count in phrase_counts.items():
|
|
bubbles.append({'text': phrase, 'value': count, 'question': q['text'], 'q_id': q_id})
|
|
|
|
total = sum(len(answers[q['id']]) for q in QUESTIONS)
|
|
return jsonify({
|
|
'bubbles': bubbles,
|
|
'total_responses': total,
|
|
'streaming': _stream_state['running'],
|
|
'stream_progress': _stream_state['progress'],
|
|
'stream_total': _stream_state['total'],
|
|
})
|
|
|
|
|
|
# QR code join slide — shown on the beamer at the start of the session
|
|
@app.route('/join')
|
|
def join():
|
|
return render_template('join.html')
|
|
|
|
|
|
# Live results display page — shows bubble chart and per-question analysis
|
|
@app.route('/display')
|
|
def display():
|
|
return render_template('display.html', questions=QUESTIONS)
|
|
|
|
|
|
# Per-question bubble chart — linked from the bottom strip on the display page
|
|
@app.route('/q/<int:q_id>')
|
|
def question_view(q_id):
|
|
q = next((x for x in QUESTIONS if x['id'] == q_id), None)
|
|
if not q:
|
|
return redirect('/display')
|
|
return render_template('question_view.html', question=q, questions=QUESTIONS)
|
|
|
|
|
|
# Receives an email address, stores it, then sends the analysis PDF in the background
|
|
@app.route('/api/email', methods=['POST'])
|
|
def capture_email():
|
|
data = request.json
|
|
email = (data.get('email') or '').strip()
|
|
if email and '@' in email and '.' in email:
|
|
store_email(email)
|
|
t = threading.Thread(target=send_analysis_report, args=(email,), daemon=True)
|
|
t.start()
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# Downloads all poll responses as a CSV file — one row per respondent
|
|
@app.route('/admin/export')
|
|
def export_csv():
|
|
n = max((len(answers[q['id']]) for q in QUESTIONS), default=0)
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(['Respondent'] + [f'Q{q["id"]}: {q["text"]}' for q in QUESTIONS])
|
|
for i in range(n):
|
|
row = [f'R{i+1}']
|
|
for q in QUESTIONS:
|
|
q_ans = answers[q['id']]
|
|
row.append(q_ans[i] if i < len(q_ans) else '')
|
|
writer.writerow(row)
|
|
output.seek(0)
|
|
return Response(
|
|
output.getvalue(),
|
|
mimetype='text/csv',
|
|
headers={'Content-Disposition': 'attachment; filename=hiacts_poll_results.csv'}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# DEMO / INTERNAL ROUTES — not linked from any attendee-facing page
|
|
# These are used for internal testing only.
|
|
# =============================================================================
|
|
|
|
# Adds a single answer to the store (used by the display page demo button)
|
|
@app.route('/submit_demo', methods=['POST'])
|
|
def submit_demo():
|
|
data = request.json
|
|
q_id = data.get('question_id')
|
|
answer = data.get('answer', '').strip()
|
|
if answer and q_id:
|
|
answers[q_id].append(answer)
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# Streams 20 simulated responses gradually over ~2 minutes (for live demo testing)
|
|
@app.route('/demo/stream', methods=['POST'])
|
|
def demo_stream():
|
|
if _stream_state['running']:
|
|
return jsonify({'error': 'Already streaming'}), 400
|
|
|
|
def run():
|
|
_stream_state['running'] = True
|
|
_stream_state['progress'] = 0
|
|
answers.clear()
|
|
|
|
participants = list(SIMULATED_RESPONSES)
|
|
random.shuffle(participants)
|
|
_stream_state['total'] = len(participants)
|
|
|
|
for i, row in enumerate(participants):
|
|
q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11 = row
|
|
answers[1].append(q1); answers[2].append(q2); answers[3].append(q3)
|
|
answers[4].append(q4); answers[5].append(q5); answers[6].append(q6)
|
|
answers[7].append(q7); answers[8].append(q8); answers[9].append(q9)
|
|
answers[10].append(q10); answers[11].append(q11)
|
|
_stream_state['progress'] = i + 1
|
|
time.sleep(6)
|
|
|
|
_stream_state['running'] = False
|
|
|
|
t = threading.Thread(target=run, daemon=True)
|
|
t.start()
|
|
return jsonify({'ok': True, 'message': '20 participants streaming over ~2 minutes'})
|
|
|
|
|
|
# Returns the current demo streaming progress
|
|
@app.route('/demo/status')
|
|
def demo_status():
|
|
return jsonify(_stream_state)
|
|
|
|
|
|
# Admin page — shows all raw responses per question with bar charts
|
|
# WARNING: this page has no password protection.
|
|
# The web dev team should restrict access to this URL in production.
|
|
@app.route('/admin')
|
|
def admin():
|
|
processed = []
|
|
for q in QUESTIONS:
|
|
q_answers = answers[q['id']]
|
|
processed.append({
|
|
'question': q,
|
|
'total': len(q_answers),
|
|
'counts': dict(Counter(q_answers).most_common())
|
|
})
|
|
return render_template('admin.html', processed=processed, emails=load_emails(), comments=load_comments())
|
|
|
|
|
|
# Clears all answers — used by the admin page reset button
|
|
@app.route('/admin/reset', methods=['POST'])
|
|
def reset():
|
|
answers.clear()
|
|
save_answers()
|
|
_session_version[0] += 1 # invalidates all existing phone sessions
|
|
_stream_state['running'] = False
|
|
_stream_state['progress'] = 0
|
|
return jsonify({'ok': True})
|
|
|
|
|
|
# Injects answers directly into the store — used by the display page demo button
|
|
@app.route('/admin/seed', methods=['POST'])
|
|
def seed():
|
|
data = request.json
|
|
for entry in data:
|
|
answers[entry['question_id']].append(entry['answer'])
|
|
save_answers()
|
|
total = sum(len(answers[q['id']]) for q in QUESTIONS)
|
|
return jsonify({'ok': True, 'total': total})
|
|
|
|
|
|
# Generates the analysis PDF on demand and returns it as a download
|
|
@app.route('/admin/report')
|
|
def report():
|
|
pdf_bytes = generate_analysis_pdf()
|
|
if not pdf_bytes:
|
|
return 'fpdf2 not installed', 500
|
|
return Response(
|
|
pdf_bytes,
|
|
mimetype='application/pdf',
|
|
headers={'Content-Disposition': 'inline; filename=HiActs_Analysis_Report.pdf'}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# START THE APP
|
|
# Runs on all network interfaces (0.0.0.0) so phones on the same wifi can connect.
|
|
# Port 5001 — access at http://localhost:5001 on this machine.
|
|
# =============================================================================
|
|
|
|
if __name__ == '__main__':
|
|
app.run(debug=True, host='0.0.0.0', port=5001, use_reloader=False)
|