A. Run PCIbex Locally

This guide walks you through the steps required to run a PCIbex experiment locally. You can either edit the local files directly or use the PCIbex Farm to download a standalone version of your experiment.

Step 1: Get Your PCIbex Experiment

Create an experiment on PCIbex Farm and download the standalone version.

Step 2: Place the static.py File

Put static.py in your experiment folder.

You can copy the code from here to make a static.py file:

static.py
#!/usr/bin/python3

import json
import os
import re
import sys

from shutil import copyfile

input_dir = next((x for n, x in enumerate(sys.argv) if n>0 and sys.argv[n-1]=="-i"), ".")
output_dir = next((x for n, x in enumerate(sys.argv) if n>0 and sys.argv[n-1]=="-o"), "static")

if not os.path.exists(output_dir):
  os.mkdir(output_dir)

CHUNKS_DICT = {}

def safe_read_file(path):
    """Safely read file with multiple encoding attempts"""
    encodings = ['utf-8', 'utf-8-sig', 'latin1', 'cp1252', 'iso-8859-1']
    
    for encoding in encodings:
        try:
            with open(path, "r", encoding=encoding) as f:
                return f.read()
        except UnicodeDecodeError:
            continue
        except Exception as e:
            print(f"Error reading {path} with {encoding}: {e}")
            continue
    
    # If all encodings fail, try reading as binary and decode with errors='replace'
    try:
        with open(path, "rb") as f:
            content = f.read()
            return content.decode('utf-8', errors='replace')
    except Exception as e:
        print(f"Failed to read {path}: {e}")
        return ""

chunk_dir = os.path.join(input_dir,"chunk_includes")
if os.path.exists(chunk_dir):
    for file in os.listdir(chunk_dir):
        path = os.path.join(chunk_dir,file)
        f = file.lower()
        if f.endswith(".html") or f.endswith(".htm") or f.endswith(".tsv") or f.endswith(".csv"):
            CHUNKS_DICT[file] = safe_read_file(path)
        else:
            copyfile(path, os.path.join(output_dir,file))

# Write chunks.js with proper JSON encoding
with open(os.path.join(output_dir,"chunks.js"), "w", encoding='utf-8') as f:
    f.write(f"window.CHUNKS_DICT = {json.dumps(CHUNKS_DICT, ensure_ascii=False)};")

# copying
js_and_css = {
  'css': {
    'ext': '.css',
    'files': [],
    'format': "<link rel='stylesheet' type='text/css' href='./css_includes/{file}'>"
  },
  'js': {
    'ext': '.js',
    'files': [],
    'format': "<script type='text/javascript' src='./js_includes/{file}'></script>"
  },
  'data': {
    'ext': '.js',
    'files': [],
    'format': "<script type='text/javascript' src='./data_includes/{file}'></script>"
  }
}

for d in js_and_css:
  dirname = f"{d}_includes"
  dirs = {
    'in': os.path.join(input_dir,dirname),
    'out': os.path.join(output_dir,dirname),
  }
  if not os.path.exists(dirs['in']):
    continue
  if not os.path.exists(dirs['out']):
    os.mkdir(dirs['out'])
  for file in os.listdir(dirs['in']):
    if not file.lower().endswith(js_and_css[d]['ext']):
      continue
    if d == "css" and not file.lower().startswith("global_"):
      prefix = file.rstrip(".css")
      in_bracket = 0
      css_lines = []
      with open(os.path.join(dirs['out'],file),"w", encoding='utf-8') as css_output:
        css_content = safe_read_file(os.path.join(dirs['in'],file))
        for line in css_content.splitlines(True):
            if in_bracket == 0:
              line = re.sub(r"^([^{]+)", lambda x: re.sub(r"\.",f".{prefix}-", x[0]), line)
            css_output.write(line)
            in_bracket += line.count('{')
            in_bracket -= line.count('}')
    else:
      copyfile(os.path.join(dirs['in'],file), os.path.join(dirs['out'],file))
    js_and_css[d]['files'].append(js_and_css[d]['format'].format(file=file))


# Copy www files with error handling
www_files = [
  "jquery.min.js","jquery-ui.min.js","jsDump.js","PluginDetect.js","util.js",
  "shuffle.js","json.js","soundmanager2-jsmin.js","backcompatcruft.js","conf.js"
]

for jsfile in www_files:
  src_path = os.path.join(input_dir,'www',jsfile)
  if os.path.exists(src_path):
    copyfile(src_path, os.path.join(output_dir,jsfile))
  else:
    print(f"Warning: {jsfile} not found in www/ directory")

# Copy main.js
main_js_path = os.path.join(input_dir,'other_includes','main.js')
if os.path.exists(main_js_path):
  copyfile(main_js_path, os.path.join(output_dir,'main.js'))
else:
  print("Warning: main.js not found in other_includes/ directory")

static_js = """
  const get_params = Object.fromEntries(
    window.location.search.replace(/^\\?/,'').split("&").map(v=>v.split('='))
  );
  const is_static = (
    "static" in get_params || 
    !window.location.protocol.toLowerCase().match(/^https?:$/)
  );

  window.__server_py_script_name__ = window.__server_py_script_name__ || "/";

  if (window.__counter_value_from_server__ === undefined)
    window.__counter_value_from_server__ = Math.round(1000 * Math.random());
  if ("withsquare" in get_params)
      window.__counter_value_from_server__ = parseInt(get_params.withsquare);

  const oldAjax = $.ajax;
  $.ajax = function(...params) {
      const splitUrl = ((params[0]||Object()).url||"").split("?");
      const localDict = window.CHUNKS_DICT||Object();
      if (splitUrl[1] == "allchunks=1" && (is_static || Object.keys(localDict).length>0))
        return params[0].success.call(this, JSON.stringify(localDict));
      else if (!is_static)
        return oldAjax.apply(this, params);
      else if (params[0].type == "POST") {
        const blob = new Blob([params[0].data], {type: "text/plain"});
        const link = document.createElement("A");
        link.download = "results_"+Date.now()+".bak";
        link.href = URL.createObjectURL(blob);
        document.body.append(link);
        link.click();
      }
      if (params[0].success instanceof Function)
        return params[0].success.call(this);
  }
"""

# Write the final HTML file with UTF-8 encoding
with open(os.path.join(output_dir,"experiment.html"), "w", encoding='utf-8') as f:
    f.write(f"""<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <script type="text/javascript" src="jquery.min.js"></script>
    <script type="text/javascript" src="jquery-ui.min.js"></script>
    <script type="text/javascript" src="jsDump.js"></script>
    <script type="text/javascript" src="PluginDetect.js"></script>
    <script type="text/javascript" src="util.js"></script>
    <script type="text/javascript" src="shuffle.js"></script>
    <script type="text/javascript" src="json.js"></script>
    <script type="text/javascript" src="soundmanager2-jsmin.js"></script>
    <script type="text/javascript" src="backcompatcruft.js"></script>
    {' '.join(sorted(js_and_css['js']['files']))}
    {' '.join(sorted(js_and_css['data']['files']))}
    <script type="text/javascript" src="conf.js"></script>
    <script type="text/javascript" src="chunks.js"></script>
    <script type="text/javascript" src="/?getcounter=1"></script>
    <script type="text/javascript">
{static_js}
    </script>
    <script type="text/javascript" src="main.js"></script>
    {' '.join(sorted(js_and_css['css']['files']))}
    <title>Experiment</title>
    <script type="text/javascript">
    document.title = conf_pageTitle;
    </script>
</head>
<body id="bod">
<noscript>
<p>You need to have Javascript enabled in order to use this page.</p>
</noscript>
</body>
</html>
""")

print("✓ Static experiment created successfully!")
print(f"✓ Open {os.path.join(output_dir, 'experiment.html')} in your browser")
Step 3: Run static.py to Generate Static Files
  1. Open the folder in File Explorer.
  2. Click in the address bar and type cmd, then press ENTER.
  3. Paste and run the following command:
python3 static.py -i . -o ../static_experiment
Step 4: Congratulations!

You will have a folder named static_experiment just outside your current folder. You can run the experiment.html file within it to start your experiment.

B. Latin Square Design

The experiment is generally shown in a latin square design. Here are some ways to implement it. Skip this section if you are not using latin square design.

Simple Way

You can create multiple instances of your experiment to show latin square design. With this approach you will have multiple static_experiment folders.

Elegant Way

Make sure you have set your experiment for latin square design, i.e. you must have a Group column in your CSV file.

If so, when you open experiment.html, add ?withsquare=1 or ?withsquare=2 at the end of the URL. This depends on how many Groups you have in your Group column.

For example, if you have 4 Groups (A, B, C, D), create 4 instances:

  • .../experiment.html?withsquare=1
  • .../experiment.html?withsquare=2
  • .../experiment.html?withsquare=3
  • .../experiment.html?withsquare=4

If you have fewer groups, use fewer instances.

C. Results

When you run your experiment, the results are stored in a file with a random number and a .bak extension.

Step 1: Create combine_penn.py

Make a combine_penn.py file with the following code:

combine_penn.py
import os
import json
import csv
import argparse
import glob

def parse_penncontroller_data(input_path):
    """
    Reads a single PennController results file and parses it 
    into headers and data rows.
    """
    try:
        with open(input_path, 'r', encoding='utf-8') as f:
            file_content = f.read()
        data = json.loads(file_content)
        headers = data[2]
        raw_data_rows = data[3]
        
        num_columns = len(headers)
        processed_rows = []

        for raw_row in raw_data_rows:
            new_row = [''] * num_columns
            for item in raw_row:
                if isinstance(item, list) and len(item) == 2:
                    col_index, value = item
                    if 0 <= col_index < num_columns:
                        new_row[col_index] = value
            processed_rows.append(new_row)
        
        print(f"  -> Successfully parsed {os.path.basename(input_path)}")
        return headers, processed_rows

    except (json.JSONDecodeError, IndexError, TypeError):
        print(f"  -> NOTICE: Skipping {os.path.basename(input_path)} "
              f"as it's not a valid PennController results file.")
        return None, None
    except Exception as e:
        print(f"  -> An unexpected error occurred with {input_path}: {e}")
        return None, None


def main():
    """
    Main function to find all .bak files, combine them, 
    and save as a single CSV.
    """
    parser = argparse.ArgumentParser(
        description="Combines PennController .bak result files "
                    "from a directory into a single CSV file.",
        formatter_class=argparse.RawTextHelpFormatter
    )
    parser.add_argument("input_dir", 
                        help="Path to the directory containing "
                             "the input .bak files.")
    parser.add_argument("output_file_path", 
                        help="Path for the final combined CSV file "
                             "(e.g., 'results/combined_data.csv').")
    
    args = parser.parse_args()

    search_pattern = os.path.join(args.input_dir, '*.bak')
    files_to_process = glob.glob(search_pattern)
    
    if not files_to_process:
        print(f"No .bak files found in directory: {args.input_dir}")
        return

    print(f"\nFound {len(files_to_process)} .bak files to process. "
          f"Other file types will be ignored.\n")

    all_rows = []
    final_headers = None

    for file_path in files_to_process:
        source_id = os.path.splitext(os.path.basename(file_path))[0]
        headers, rows = parse_penncontroller_data(file_path)

        if headers is None or rows is None:
            continue

        if final_headers is None:
            final_headers = ["SourceFile"] + headers

        for row in rows:
            all_rows.append([source_id] + row)

    if not all_rows:
        print("\nNo valid data was processed. "
              "Output file will not be created.")
        return

    output_dir = os.path.dirname(args.output_file_path)
    if output_dir:
        os.makedirs(output_dir, exist_ok=True)

    try:
        with open(args.output_file_path, 'w', 
                  newline='', encoding='utf-8') as f_out:
            writer = csv.writer(f_out)
            writer.writerow(final_headers)
            writer.writerows(all_rows)
        
        print(f"\n✅ Success! All data combined and saved to: "
              f"{args.output_file_path}")
    except IOError as e:
        print(f"\n❌ Error: Could not write to output file "
              f"'{args.output_file_path}'. Reason: {e}")

if __name__ == "__main__":
    main()
Step 2: Organise Your .bak Files

Place your .bak files in an input_data_bak folder like this:

├── input_data_bak/
│   ├── 23543545.bak
│   ├── 87654321.bak
│   └── 99991111.bak
Step 3: Place combine_penn.py

Put your combine_penn.py file just outside that folder:

├── combine_penn.py

├── input_data_bak/
│   ├── 23543545.bak
│   ├── 87654321.bak
│   └── 99991111.bak

└── final_results/  (can be empty — the script will create it)
Step 4: Run the Script
  1. Open the folder in File Explorer.
  2. Click in the address bar and type cmd, then press ENTER.
  3. Paste and run the following command:
python combine_penn.py input_data_bak final_results/combined_data.csv