"""
Generate oTree all-apps CSV export for Combined_3lights (session: 3Lights_T1),
80 participants × 5 rounds × 16 guesses per round.
Output format exactly matches what oTree 6.x produces when you download
the all-apps (all data) CSV export from the admin panel after real
participants complete the experiment.
Key differences from the 2-light experiment:
- 3 observable lights (Red, Blue, Green) → 8 light configurations
- N_GUESSES = 16 (2 per configuration × 8 configs)
- Row vectors have 5 elements: [Red, Blue, Green, Others, Sound]
- Light configs stored as "(r, g, b)" 3-tuples
- original_color = 0 (permutation index 0 = standard, since RANDOMIZE_COLORS = 0)
- Machine names: AND, OR, EITHER, JOINT, INHIBIT (same 5 treatments)
- Frequencies taken verbatim from Combined_3lights/__init__.py case_list
- Payment via FeedbackDrawSingle (one random machine, one random draw,
compared against a random observation from the case data)
"""
import csv
import json
import random
import string
import datetime
import numpy as np
import os
random.seed(42)
np.random.seed(42)
# ── Constants ─────────────────────────────────────────────────────────────────
N_PLAYERS = 80
N_ROUNDS = 5
N_GUESSES = 16 # 2 per config × 8 configs
GUESSES_PER_CONFIG = 2
SESSION_CODE = 'p9q7r3s5'
SESSION_START = datetime.datetime(2025, 9, 15, 9, 0, 0)
PARTICIPATION_FEE = 3.0
RW_CURRENCY_PER_PT = 3.0
RAVENS_PPQ = 15
# ── Case definition for 3 lights ──────────────────────────────────────────────
# Columns: [Red, Blue, Green, Others, Sound]
# 16 rows = 8 light configs × 2 sound possibilities each
case2 = [
[0, 0, 0, 0, 0], # config (0,0,0), sound=0
[0, 0, 0, 0, 1], # config (0,0,0), sound=1
[0, 0, 1, 0, 0], # config (0,0,1), sound=0
[0, 0, 1, 0, 1], # config (0,0,1), sound=1
[0, 1, 0, 0, 0], # config (0,1,0), sound=0
[0, 1, 0, 0, 1], # config (0,1,0), sound=1
[0, 1, 1, 0, 0], # config (0,1,1), sound=0
[0, 1, 1, 0, 1], # config (0,1,1), sound=1
[1, 0, 0, 0, 0], # config (1,0,0), sound=0
[1, 0, 0, 0, 1], # config (1,0,0), sound=1
[1, 0, 1, 0, 0], # config (1,0,1), sound=0
[1, 0, 1, 0, 1], # config (1,0,1), sound=1
[1, 1, 0, 0, 0], # config (1,1,0), sound=0
[1, 1, 0, 0, 1], # config (1,1,0), sound=1
[1, 1, 1, 0, 0], # config (1,1,1), sound=0
[1, 1, 1, 0, 1], # config (1,1,1), sound=1
]
# Frequencies from Combined_3lights/__init__.py (Instructions.before_next_page)
# Index i corresponds to case2[i]; value = number of times row i appears
MACHINE_FREQS = {
'AND': [4, 0, 2, 1, 9, 0, 3, 1, 1, 0, 0, 0, 1, 2, 1, 5], # AND DIFFICULT
'OR': [4, 1, 5, 1, 0, 2, 0, 1, 0, 2, 0, 2, 0, 5, 1, 5], # OR EASY
'EITHER': [7, 0, 3, 1, 1, 1, 0, 1, 1, 3, 0, 9, 1, 0, 1, 1], # EITHER DIFFICULT
'JOINT': [0, 3, 1, 3, 3, 1, 3, 0, 4, 1, 4, 0, 0, 3, 1, 3], # JOINT EASY
'INHIBIT': [4, 0, 1, 1, 4, 0, 2, 0, 0, 4, 0, 10, 2, 1, 1, 1], # INHIBIT DIFFICULT
}
MACHINE_NAMES = ['AND', 'OR', 'EITHER', 'JOINT', 'INHIBIT']
# Correct predictions placeholder stored at participant level (as in the app)
# Keys = machine name '1L' (placeholder, not used in FeedbackDrawSingle payment)
CORRECT_PREDICTIONS = {
'1L': {
'(0, 0, 0)': 0, '(0, 0, 1)': 0, '(0, 1, 0)': 0, '(0, 1, 1)': 0,
'(1, 0, 0)': 0, '(1, 0, 1)': 0, '(1, 1, 0)': 0, '(1, 1, 1)': 0,
}
}
# Comprehension answers (all correct on first attempt for bots — same questions)
CQ_CORRECT = {'cq1': 1, 'cq2': 2, 'cq3': 3, 'cq4': 2, 'cq5': 3}
# ── Free-text pools ───────────────────────────────────────────────────────────
NOTES_POOL = [
"The red light seems to be the main predictor of the sound.",
"Both red and blue lights appear to need to be on for the ding.",
"Blue light on its own seems to prevent the sound.",
"When only red is on there is almost always a ding.",
"It looks like either red or blue is sufficient to trigger the sound.",
"Sound only when both observable lights are off – very counterintuitive.",
"Red alone seems sufficient. Blue and green add nothing.",
"Pattern unclear after observing. The green light seems irrelevant.",
"Sound correlates strongly with both red and blue lights being on.",
"The sound occurred when red was on regardless of blue or green.",
"I noticed the blue light was on most of the time when sound occurred.",
"I cannot find a clear pattern. Maybe it depends on only two of the lights.",
"Red light on with blue off seems most predictive of a ding.",
"The rule seems to depend on some combination of red and blue lights.",
"Most sounds occurred when red was off and blue was on.",
"Green light does not seem to matter at all for the sound.",
"I think the rule involves both red and blue but not green.",
"The unobservable lights might be driving the sound here.",
]
EXAMPLE_NOTES_POOL = [
"I think the red light predicts the sound most of the time.",
"Both red and blue lights together seem to produce the ding.",
"The pattern here is not immediately obvious to me.",
"It seems like when both lights are on the ding is more likely.",
"I noticed that the blue light was often on when there was no sound.",
]
STRATEGY_POOL = [
"I focused on which combination of red and blue lights most reliably predicted the ding.",
"I tracked whether any single light was sufficient or if a combination was needed.",
"I tried to identify which configuration always produced the sound regardless of green.",
"I learned the underlying rule by looking at the observation table carefully.",
"I noticed that the green light did not seem to affect the sound at all.",
"I paid attention to how often each red-blue combination appeared with a ding.",
"My strategy was to treat the red and blue lights as the key causal variables.",
"I looked for patterns across the whole table, focusing on red and blue.",
]
COMMENT_POOL = [
"The experiment was challenging but interesting.",
"Some machines were much harder than others.",
"I found the observation tables very helpful for spotting patterns.",
"The task was mentally demanding but rewarding.",
"I struggled with some of the more complex patterns.",
"Interesting study. I would be curious to learn the actual rules.",
"The tasks varied a lot in difficulty. Some were clear, others not at all.",
"I enjoyed the task overall. It reminded me of logical puzzles.",
"Having three lights made it harder to identify which ones mattered.",
]
# ── HTML table helpers ────────────────────────────────────────────────────────
def html_table_freqs_original_3L(case, freq):
"""Generate observation HTML table for a 3-light case (color_perm=0, standard).
Columns in case: [Red, Blue, Green, Others, Sound]
Returns (html_string, shuffled_matrix).
"""
# Build matrix with repetitions and shuffle
h = np.tile(case[0], (freq[0], 1))
for i in range(len(freq) - 1):
h = np.vstack((h, np.tile(case[i + 1], (freq[i + 1], 1))))
np.random.shuffle(h)
n = sum(freq)
# color_perm=0: r_src=0, b_src=1, g_src=2 (standard layout)
red_cells = ['
' if h[i][0] == 1 else '' for i in range(n)]
blue_cells = ['' if h[i][1] == 1 else '' for i in range(n)]
green_cells = ['' if h[i][2] == 1 else '' for i in range(n)]
other_cells = ['?'] * n
sound_cells = [
'♪ DING ♪' if h[i][4] == 1
else '-'
for i in range(n)
]
html_mat = np.column_stack((red_cells, blue_cells, green_cells, other_cells, sound_cells))
table = (
''
''
'| Red Light | '
'Blue Light | '
'Green Light | '
'Other Lights | '
'Sound |
'
)
for i in range(n):
table += (
''
'| ' + html_mat[i][0] + ' | '
'' + html_mat[i][1] + ' | '
'' + html_mat[i][2] + ' | '
'' + html_mat[i][3] + ' | '
'' + html_mat[i][4] + ' |
'
)
table += '
'
return table, h
def _draw_observation_sound(case_def, freq, config_tuple):
"""Given a 3-light case + freq, draw a random row matching config_tuple
and return its sound value (last column). Returns None if no match."""
config_cols = len(case_def[0]) - 2 # exclude Others and Sound
matching_rows, matching_weights = [], []
for i, row in enumerate(case_def):
if tuple(int(x) for x in row[:config_cols]) == config_tuple:
matching_rows.append(row)
matching_weights.append(freq[i] if freq[i] > 0 else 1)
if not matching_rows:
return None
drawn = random.choices(matching_rows, weights=matching_weights, k=1)[0]
return int(drawn[-1])
def sample_balanced_guess_rows(case_def, freq, guesses_per_config=2):
"""Sample N_GUESSES rows: guesses_per_config per light config,
arranged so no two consecutive rows share the same light config.
For 3-light cases (5 columns): light config = first 3 elements.
"""
config_cols = len(case_def[0]) - 2 # exclude Others and Sound
config_groups = {}
for i, row in enumerate(case_def):
config = tuple(row[:config_cols])
config_groups.setdefault(config, {'rows': [], 'weights': []})
config_groups[config]['rows'].append(list(row))
config_groups[config]['weights'].append(freq[i])
config_pools = {}
for config, group in config_groups.items():
w = group['weights']
if sum(w) == 0:
w = [1] * len(w)
config_pools[config] = random.choices(group['rows'], weights=w, k=guesses_per_config)
result = []
prev = None
total = sum(len(v) for v in config_pools.values())
for _ in range(total):
avail = {k: v for k, v in config_pools.items() if len(v) > 0 and k != prev}
if not avail:
avail = {k: v for k, v in config_pools.items() if len(v) > 0}
max_cnt = max(len(v) for v in avail.values())
top = [k for k, v in avail.items() if len(v) == max_cnt]
chosen = random.choice(top)
result.append(config_pools[chosen].pop())
prev = chosen
return result
# ── Small utilities ───────────────────────────────────────────────────────────
def rand_code():
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
def rand_prolific_id():
return ''.join(random.choices('0123456789abcdef', k=24))
def sanitize(value):
"""Replicate oTree's per-app CSV cell serialisation."""
if value is None:
return ''
if isinstance(value, str):
return value.replace('\n', ' ').replace('\r', ' ')
return value
# ── Column header definitions ─────────────────────────────────────────────────
PART_STD_COLS = [
'participant.id_in_session', 'participant.code', 'participant.label',
'participant._is_bot', 'participant._index_in_pages', 'participant._max_page_index',
'participant._current_app_name', 'participant._current_page_name',
'participant.time_started_utc', 'participant.visited',
'participant.mturk_worker_id', 'participant.mturk_assignment_id',
'participant.payoff',
]
PART_FIELD_COLS = [
'participant.notes', 'participant.cases_ordered', 'participant.realized_cases',
'participant.light_list', 'participant.guesses', 'participant.order_names',
'participant.sound', 'participant.current_case', 'participant.original_color',
'participant.guess_values', 'participant.guess_configs', 'participant.correct_predictions',
]
SESSION_COLS = [
'session.code', 'session.label',
'session.mturk_HITId', 'session.mturk_HITGroupId', 'session.comment',
'session.is_demo',
'session.config.name', 'session.config.participation_fee',
'session.config.real_world_currency_per_point',
'session.config.ravens_payoff_per_question',
]
# Player custom fields for Combined_3lights (in model definition order)
PLAYER_CUSTOM_COLS = (
['ID_subject', 'notes', 'Example_notes',
'case', 'machine_name', 'case_order',
'error', 'original_color', 'table',
'explanation', 'bonus_message_effect', 'advice_text',
'predicted_correct_other', 'predicted_correct_self', 'certainty',
'difficulty', 'difficulty_certainty',
'prediction_strategy', 'final_comments',
'page_load_ts', 'time_on_page']
+ [f'time_guess_{k}' for k in range(1, N_GUESSES + 1)]
+ ['num_wrong', 'cq1', 'cq2', 'cq3', 'cq4', 'cq5', 'n_lights']
+ [f'guess{k}' for k in range(1, N_GUESSES + 1)]
+ ['guess_example']
+ [f'row{k}' for k in range(1, N_GUESSES + 1)]
+ ['guess_configs_json', 'order_names_json']
)
PLAYER_BUILTIN = ['player.id_in_group', 'player.role', 'player.payoff']
PLAYER_HEADER = PLAYER_BUILTIN + [f'player.{c}' for c in PLAYER_CUSTOM_COLS]
TAIL_COLS = ['group.id_in_subsession', 'subsession.round_number']
# Per-round header block (prefix will be prepended)
ROUND_COLS = PLAYER_HEADER + TAIL_COLS
PAY_CUSTOM_COLS = ['payment_details', 'bonus_total', 'n_machines', 'n_correct_drawn']
PAY_PLAYER_HEADER = PLAYER_BUILTIN + [f'player.{c}' for c in PAY_CUSTOM_COLS]
PAY_ROUND_COLS = PAY_PLAYER_HEADER + TAIL_COLS
REDIRECT_PLAYER_HEADER = PLAYER_BUILTIN
REDIRECT_ROUND_COLS = REDIRECT_PLAYER_HEADER + TAIL_COLS
def build_header():
header = PART_STD_COLS + PART_FIELD_COLS + SESSION_COLS
for r in range(1, N_ROUNDS + 1):
for col in ROUND_COLS:
header.append(f'Combined_3lights.{r}.{col}')
for col in PAY_ROUND_COLS:
header.append(f'Pay.1.{col}')
for col in REDIRECT_ROUND_COLS:
header.append(f'Redirect.1.{col}')
return header
# ── Data generation ───────────────────────────────────────────────────────────
rows = []
for pid in range(1, N_PLAYERS + 1):
p_code = rand_code()
prolific = rand_prolific_id()
t_start = SESSION_START + datetime.timedelta(
minutes=random.randint(pid * 2 - 2, pid * 2 + 30))
t_start_s = str(t_start)
# Shuffle machine order for this participant
order = MACHINE_NAMES.copy()
random.shuffle(order)
# original_color = 0 for all rounds (RANDOMIZE_COLORS=0; perm 0 = standard)
original_colors = [0] * N_ROUNDS
# ── Participant-level accumulators ─────────────────────────────────────
p_notes = []
p_guesses = [] # correctness (1/0), N_ROUNDS × N_GUESSES values
p_sound = [] # actual sound outcomes
p_guess_values = [] # player's guess (0/1)
p_guess_configs = [] # config string e.g. "(0, 1, 0)"
p_realized = [] # shuffled observation matrix per round
p_current_case = None
cases_ordered = [[case2, MACHINE_FREQS[m]] for m in order]
# Comprehension (round 1 only)
cqs = CQ_CORRECT.copy()
num_wrong_r1 = random.randint(0, 5)
guess_ex_val = random.randint(0, 1)
example_notes = random.choice(EXAMPLE_NOTES_POOL)
round_records = []
for r_idx in range(N_ROUNDS):
machine = order[r_idx]
freq = MACHINE_FREQS[machine]
orig_c = 0 # always permutation 0 (standard)
# Generate observation table (3-light, standard color order)
table_html, matrix = html_table_freqs_original_3L(case2, freq)
case_str = str(matrix)
matrix_list = matrix.tolist()
p_realized.append(matrix_list)
p_current_case = matrix_list
# Sample 16 guess rows: 2 per light config (8 configs × 2 = 16)
guess_rows = sample_balanced_guess_rows(case2, freq, GUESSES_PER_CONFIG)
row_strs = [str(np.array(guess_rows[i])) for i in range(N_GUESSES)]
# Bot guesses (~65% accuracy)
player_guesses = []
round_configs = []
for row in guess_rows:
sound_val = row[-1] # last element = sound
config = tuple(row[:3]) # first 3 = (Red, Blue, Green)
guess = sound_val if random.random() < 0.65 else (1 - sound_val)
player_guesses.append(int(guess))
p_guesses.append(1 if guess == sound_val else 0)
p_sound.append(sound_val)
p_guess_values.append(int(guess))
round_configs.append(str(config))
p_guess_configs.append(str(config))
p_notes.append(random.choice(NOTES_POOL))
guess_configs_json_val = json.dumps(round_configs)
order_names_json_val = json.dumps(order)
case_order_val = str(cases_ordered) if r_idx == 0 else None
page_load_ts = t_start.timestamp() + r_idx * 500 + random.uniform(10, 60)
time_on_page = round(random.uniform(30.0, 180.0), 4)
time_guesses = [round(random.uniform(1.0, 12.0), 4) for _ in range(N_GUESSES)]
predicted_correct_self = random.randint(5, 16)
certainty = random.choice(range(0, 101, 5))
is_last = (r_idx == N_ROUNDS - 1)
difficulty = random.randint(1, 10) if is_last else None
difficulty_certainty = random.choice(range(0, 101, 5)) if is_last else None
prediction_strategy = random.choice(STRATEGY_POOL) if is_last else None
final_comments = random.choice(COMMENT_POOL) if is_last else None
round_records.append(dict(
machine=machine, table_html=table_html,
case_str=case_str, case_order_val=case_order_val,
row_strs=row_strs, player_guesses=player_guesses,
guess_configs_json=guess_configs_json_val,
order_names_json=order_names_json_val,
predicted_correct_self=predicted_correct_self, certainty=certainty,
difficulty=difficulty, difficulty_certainty=difficulty_certainty,
prediction_strategy=prediction_strategy, final_comments=final_comments,
page_load_ts=round(page_load_ts, 4), time_on_page=time_on_page,
time_guesses=time_guesses,
))
# ── Participant-level fields (serialised to JSON as oTree 6.x does) ────
pf_notes = json.dumps(p_notes)
pf_cases_ordered = json.dumps(cases_ordered)
pf_realized_cases = json.dumps(p_realized)
pf_light_list = ''
pf_guesses = json.dumps(p_guesses)
pf_order_names = json.dumps(order)
pf_sound = json.dumps(p_sound)
pf_current_case = json.dumps(p_current_case)
pf_original_color = json.dumps([0] * N_ROUNDS) # perm 0 = standard for all rounds
pf_guess_values = json.dumps(p_guess_values)
pf_guess_configs = json.dumps(p_guess_configs)
pf_correct_preds = json.dumps(CORRECT_PREDICTIONS)
# ── Simulate FeedbackDrawSingle payment ───────────────────────────────
chosen_m_idx = random.randint(0, N_ROUNDS - 1)
machine_name = order[chosen_m_idx]
freq_pay = MACHINE_FREQS[machine_name]
start = chosen_m_idx * N_GUESSES
m_gv = p_guess_values[start:start + N_GUESSES]
m_gc = p_guess_configs[start:start + N_GUESSES]
draw_idx = random.randint(0, N_GUESSES - 1)
drawn_guess = m_gv[draw_idx]
drawn_config = m_gc[draw_idx]
config_tuple = tuple(int(x) for x in drawn_config.strip('()').split(','))
drawn_sound = _draw_observation_sound(case2, freq_pay, config_tuple)
is_correct = (drawn_guess == drawn_sound) if drawn_sound is not None else False
n_correct_pay = 1 if is_correct else 0
pay_details = [{
'machine': machine_name,
'draw_index': draw_idx + 1,
'config': drawn_config,
'guess': drawn_guess,
'drawn_sound': drawn_sound,
'is_correct': is_correct,
}]
bonus_total = n_correct_pay * 1.50
# ── Build the single wide row for this participant ─────────────────────
# Participant-level standard columns
part_std_vals = [
pid, p_code, '',
0, 142, 142, # _is_bot=0; ~142 pages for 3-light experiment
'Redirect', 'Redirect',
t_start_s, True, '', '',
bonus_total, # participant.payoff = bonus earned
]
# Participant field columns
part_field_vals = [
pf_notes, pf_cases_ordered, pf_realized_cases,
pf_light_list, pf_guesses, pf_order_names,
pf_sound, pf_current_case, pf_original_color,
pf_guess_values, pf_guess_configs, pf_correct_preds,
]
# Session columns
session_vals = [
SESSION_CODE, '', '', '', '',
False,
'3Lights_T1', PARTICIPATION_FEE, RW_CURRENCY_PER_PT, RAVENS_PPQ,
]
# ── Per-round player values ────────────────────────────────────────────
round_vals = []
for r_idx, rd in enumerate(round_records):
r = r_idx + 1
is_r1 = r == 1
pv = {
'id_in_group': 1,
'role': '',
'payoff': 0.0,
'ID_subject': prolific if is_r1 else '',
'notes': p_notes[r_idx],
'Example_notes': example_notes if is_r1 else '',
'case': sanitize(rd['case_str']),
'machine_name': rd['machine'],
'case_order': str(cases_ordered) if is_r1 else '',
'error': 0,
'original_color': 0, # perm 0 = standard
'table': rd['table_html'],
'explanation': '',
'bonus_message_effect': '',
'advice_text': '',
'predicted_correct_other': '',
'predicted_correct_self': rd['predicted_correct_self'],
'certainty': rd['certainty'],
'difficulty': rd['difficulty'] if rd['difficulty'] is not None else '',
'difficulty_certainty': rd['difficulty_certainty'] if rd['difficulty_certainty'] is not None else '',
'prediction_strategy': rd['prediction_strategy'] if rd['prediction_strategy'] is not None else '',
'final_comments': rd['final_comments'] if rd['final_comments'] is not None else '',
'page_load_ts': rd['page_load_ts'],
'time_on_page': rd['time_on_page'],
**{f'time_guess_{k}': rd['time_guesses'][k - 1] for k in range(1, N_GUESSES + 1)},
'num_wrong': num_wrong_r1 if is_r1 else 0,
'cq1': cqs['cq1'] if is_r1 else '',
'cq2': cqs['cq2'] if is_r1 else '',
'cq3': cqs['cq3'] if is_r1 else '',
'cq4': cqs['cq4'] if is_r1 else '',
'cq5': cqs['cq5'] if is_r1 else '',
'n_lights': 5, # 5 columns in case2
**{f'guess{k}': rd['player_guesses'][k - 1] for k in range(1, N_GUESSES + 1)},
'guess_example': guess_ex_val if is_r1 else '',
**{f'row{k}': rd['row_strs'][k - 1] for k in range(1, N_GUESSES + 1)},
'guess_configs_json': rd['guess_configs_json'],
'order_names_json': rd['order_names_json'],
}
# Flatten in PLAYER_CUSTOM_COLS order
player_row = (
[pv['id_in_group'], pv['role'], pv['payoff']]
+ [pv[c] for c in PLAYER_CUSTOM_COLS]
)
round_vals.extend(player_row + [1, r]) # group.id=1, subsession.round_number=r
# ── Pay row values ─────────────────────────────────────────────────────
pay_vals = (
[1, '', bonus_total,
json.dumps(pay_details), bonus_total, N_ROUNDS, n_correct_pay]
+ [1, 1]
)
# ── Redirect row values ────────────────────────────────────────────────
redirect_vals = [1, '', 0.0, 1, 1]
# ── Assemble full row ──────────────────────────────────────────────────
full_row = part_std_vals + part_field_vals + session_vals + round_vals + pay_vals + redirect_vals
rows.append(full_row)
# ── Write output ──────────────────────────────────────────────────────────────
out_dir = os.path.dirname(__file__)
out_file = os.path.join(out_dir, '3lights_simulation.csv')
header = build_header()
assert len(header) == len(rows[0]), (
f"Column count mismatch: {len(header)} headers vs {len(rows[0])} values"
)
with open(out_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(header)
writer.writerows(rows)
print(f"3lights_simulation.csv : {len(rows)} rows × {len(header)} columns")
print("Done.")