oTreeでオンライン実験をした時に苦労したこと&カスタマイズ（覚書）

# oTreeとは

oTree はオンライン実験を作ることができるDjango をベースにしたフレームワークです。Python(変数や関数の管理) と HTML（+JS+CSS：ページのデザイン）で作ります。
特に、囚人のジレンマ・公共財ゲームのような、複数のプレイヤーがリアルタイムで相互作用する行動実験を作るために使われている印象です。

oTreeの紹介自体は他の記事に任せることにして、ここではオンライン実験を実施した時に苦労した＆工夫したポイントなどをダラダラ記していきます。気が向いたら情報を更新するかもしれません。

--

* 入門者は後藤晶さんのチュートリアル資料が素晴らしくわかりやすいので、これを参照するとスムーズに始められます。

https://github.com/akrgt/otree_2019KG

* 最近(2021年9月)、公式ドキュメントが日本語訳されたので、日本人ユーザにも優しくなりました。

https://otree.readthedocs.io/ja/latest/index.html

* otree help & discussionを参照すると、Advancedな情報も見つかります。
* 自分で質問を投げてみると他のユーザやoTreeの開発者本人が答えてくれます。

https://groups.google.com/g/otree

--

oTreeにはデフォルトで多くの便利な機能・メソッドが用意されています。
特に経済ゲームなどを作る上では使い勝手がいいのですが、実験のデザインによっては自分で欲しい機能を実装していく必要がありました。また、oTreeの様々な仕様を知らずに使い始めると思った通りの挙動をしてくれないので、色々と苦労しました。


# oTreeのカスタマイズ

[oTree Studio](https://otree.readthedocs.io/en/latest/studio.html#studio)を使えば、GUIで実験を作ることが出来ます。
しかし、修正内容を反映するために、毎回.otreezipファイルをダウンロードする必要があるため、こまめに修正を確認したい場合は不便に感じました。

## データの保存

もともと実験データは手動でダウンロード出来ます　(c.f. [Export Data](https://otree.readthedocs.io/en/latest/admin.html?highlight=data#export-data))。
ただし、デフォルトのデータテーブルには、GroupやPlayerというClassの下で作られた[Field](https://otree.readthedocs.io/en/latest/models.html#fields)のみが含まれ、すべての変数を保存してくれるわけではありません。
データとして保存したい変数はすべてFieldとして作成してもいいのですが、使い勝手が悪いことも多く、自分でPython（pandas）のDataFrameを作ることにしました。

自分でDataFrameを作って良かったことはいくつかありました。

* 保存する変数を自由に選べる
* データの形式を自由に選べる
    * デフォルトのデータテーブルはwide型だが、分析の都合上long型が良かった
* データの保存し忘れがない
    * ラウンドごとにデータを追記し、自分のラボのサーバにcsvを書き出すようにした



## 外部ファイルへのアクセス

実験の設計上、「1人目の参加者のデータを出力→翌日、2人目の参加者が1人目の参加者のデータを読み込み、データに応じて画面に表示する内容を変更」という処理が必要でした。

ローカルPCで実験を実施する上では、（1）テキストファイルをプロジェクトフォルダ内に出力する、（2）そのファイルへのパスを書く、でアクセスできていました。
しかし、実験プログラムをサーバにアップロードすると、相対パスで書いていたとしてもパスが機能しなくなりました。そもそも実験中に出力される1人目の参加者のデータがHerokuにアップしたプロジェクトフォルダ内にきちんと保存されているのか、虚空へ消えていったのかが不明。。。

サーバ（oTreeが推奨している[Heroku](https://jp.heroku.com/)を使用）へ実験プログラムをアップロードすると、普通にファイルパスを書いても通らない問題がありました。

解決方法としては、テキストファイルをプロジェクトフォルダ内に出力する代わりに、外部サーバに出力することにしました。Pythonの[paramiko](https://www.paramiko.org/)というパッケージで外部サーバへSSH接続することが出来ました。
下のコード例はpandasを使ってcsvを書き出しています。

```python
with paramiko.SSHClient() as ssh:
	# 初回ログイン時に「Are you sure you want to continue connecting (yes/no)?」ときかれても問題なく接続できる
	ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

	# ssh接続
	ssh.connect('サーバのIPアドレス', port=22, username='username', password='password')
	sftp = ssh.open_sftp()

	#DataFrameをcsvに出力
	with sftp.open("dir/file.csv", "w") as f_w:
		df.to_csv(f_w,index=False)
```

## アニメーションの使用

HTML,CSS,JavaScriptで作ったアニメーションをoTreeに組み込無事も出来ます。
ただし、oTreeでアニメーションを使うときにはファイルの位置やパスの書き方に気をつける必要があります。アニメーションを制御するJSファイルや画像ファイルとアニメーションを表示したいhtmlファイルを同じディレクトリに置いたとしても、パスが機能しないためhtmlからJSを読み込めないのです。

```
project/
　├ 実験課題名/
　│　├ models.py
　│　├ pages.py
　│　└ template/
　│　　　└ 実験課題名/
　│　　　　└ 画面1.html/
　│　　　　└ 画面2.html/
　├ settings.py
　├ requirements_base.txt
　├ requirements.txt
　├ _static
　│　└ global/
　│　　　└ xxx.css
　│　　　└ xxx.js
　│　　　└ xxx.jpg
```

oTreeのプロジェクトフォルダは上記のような構造なのですが、.css, .js, .jpgなどのファイルは[_static](https://otree.readthedocs.io/en/latest/misc/advanced.html?highlight=static#static-files)というフォルダに入れておかないとアクセスできないようでした。_staticに入れたファイルへのパスを以下のように書くと無事アクセスできました。

ちなみに、初めは自分でJSのスクリプトを書いて、canvas要素でアニメーション表示をしていたのですが、複雑なアニメーションを作るのは大変だったので、[Adobe Animate](https://www.adobe.com/jp/products/animate.html)に助けてもらうことにしました。
AnimateによってGUIででアニメーションを作る→HTMLとJSのファイルを書き出すということが出来るので、oTreeに組み込むのも楽でした。（JavaScriptはアニメーションを作れるライブラリーが豊富なので、得意な人はもっと自由に作れるかも→ [10+ Best JavaScript Animation Libraries to Use in 2021](https://www.codeinwp.com/blog/best-javascript-animation-libraries/)）


```html
<!- Adobe Animateで作ったアニメーションをoTreeに埋め込むときのコード例>
<!- 出力されたHTMLコードのうちパスだけ書き換えればOK>
<body>
	<div id="path" title="{% static '実験名/js/images/test.png' %}" /></div>
	<!- _staticフォルダに置いているJSファイルの方でファイルの方では、staticパスを使えないので、ここでパスを作っておいて、これをJSファイルの方でgetElementByIdによって取得させる必要がある>

	<div id="animation_container" style="background-color:rgba(255, 255, 255, 1.00); width:500px; height:200px">
		<canvas id="canvas" width="500" height="200" style="position: absolute; display: block; background-color:rgba(255, 255, 255, 1.00);"></canvas>
		<div id="dom_overlay_container" style="pointer-events:none; overflow:hidden; width:500px; height:200px; position: absolute; left: 0px; top: 0px; display: block;">
		</div>
	</div>
<script src="https://code.createjs.com/1.0.0/createjs.min.js"></script>
<script src="{% static '実験名/js/animation.js' %}"></script>
<script>
var canvas, stage, exportRoot, anim_container, dom_overlay_container, fnStartAnimation;
window.onload =function init() {
	canvas = document.getElementById("canvas");
	anim_container = document.getElementById("animation_container");
	dom_overlay_container = document.getElementById("dom_overlay_container");
	var comp=AdobeAn.getComposition("19B68DEDB8325F4A91530BC8C31DF4AB");
	var lib=comp.getLibrary();
	var loader = new createjs.LoadQueue(false);
	loader.addEventListener("fileload", function(evt){handleFileLoad(evt,comp)});
	loader.addEventListener("complete", function(evt){handleComplete(evt,comp)});
	var lib=comp.getLibrary();
	loader.loadManifest(lib.properties.manifest);
}
function handleFileLoad(evt, comp) {
	var images=comp.getImages();	
	if (evt && (evt.item.type == "image")) { images[evt.item.id] = evt.result; }	
}
function handleComplete(evt,comp) {
	//This function is always called, irrespective of the content. You can use the variable "stage" after it is created in token create_stage.
	var lib=comp.getLibrary();
	var ss=comp.getSpriteSheet();
	var queue = evt.target;
	var ssMetadata = lib.ssMetadata;
	for(i=0; i<ssMetadata.length; i++) {
		ss[ssMetadata[i].name] = new createjs.SpriteSheet( {"images": [queue.getResult(ssMetadata[i].name)], "frames": ssMetadata[i].frames} )
	}
	exportRoot = new lib.ZZc7G47c8WDa();
	stage = new lib.Stage(canvas);	
	//Registers the "tick" event listener.
	fnStartAnimation = function() {
		stage.addChild(exportRoot);
		createjs.Ticker.framerate = lib.properties.fps;
		createjs.Ticker.addEventListener("tick", stage);
	}	    
	//Code to support hidpi screens and responsive scaling.
	AdobeAn.makeResponsive(false,'both',false,1,[canvas,anim_container,dom_overlay_container]);	
	AdobeAn.compositionLoaded(lib.properties.id);
	fnStartAnimation();
}
</script>
```

## 複数のアニメーションを表示

複数のアニメーションを綺麗に並べるためには、[iframe](https://developer.mozilla.org/ja/docs/Web/HTML/Element/iframe)と[table](https://developer.mozilla.org/ja/docs/Web/HTML/Element/table)を組み合わせるのが簡単でした。アニメーションを表示するためのhtml, JS, 画像ファイルをどこかのクラウド上にアップしておいて、htmlのパスを取得しておくだけです。

```
<body>
<font size="4">
<table>
<tr>
  <th>1</th>
  <th>2</th>
</tr>
<tr>
  <td>
  <iframe src={{animation_path1}} height="200" width="500", style="border:none;" title="animation1"></iframe>
  </td>
  <td>
  <iframe src={{animation_path2}} height="200" width="500", style="border:none;" title="animation2"></iframe>
  </td>
</tr>
<tr>
  <th>3</th>
  <th>4</th>
</tr>
<tr>
  <td>
  <iframe src={{animation_path3}} height="200" width="500", style="border:none;" title="animation3"></iframe>
  </td>
  <td>
  <iframe src={{animation_path4}} height="200" width="500", style="border:none;" title="animation4"></iframe>
  </td>
</tr>
</table>
</font>
</body>
```

ただし、クラウドとの通信が入るので、実際にアニメーションが表示されるまでの時間にラグが生じやすくなります。簡単な解決策は、事前に全てのアニメーションを一度読み込んでおくことでした。実験の初めの画面に全てのアニメーションのiframeを　height="0" width="0"　で表示する（実際には画面に表示されない）ようにしておくと、その後の別の画面で再表示する際のラグがほとんどなくなりました。オンライン実験をする際は、通信速度の影響をできるだけ除くための工夫も必要だと思いますが、誰かそういうTipsまとめてくれてないかな...

```html
<iframe src=https://test1.html height="0" width="0", style="border:none;" title="animation1"></iframe>
<iframe src=https://test2.html height="0" width="0", style="border:none;" title="animation2"></iframe>
<iframe src=https://test3.html height="0" width="0", style="border:none;" title="animation3"></iframe>
```


## ボタンの設置

~~書くのに疲れてきたので~~ もはや、単なるhtmlのtipsなので割愛しますが、文字や数値をキーボードでタイプしてもらう代わりに、ボタンを作って入力してもらうことも出来ます。
心理学の実験や調査をするときには、「できるだけ参加者・回答者の負担を減らしましょう」と学部の授業などで教わりますし、自分でボタンを作りましょう。

# サーバ上で挙動がおかしい...

ローカルでデバッグしている時とサーバ（Heroku）上で動かしている時で、細かな違いがありました。ローカルの開発環境やバージョンの違いによるもの？

### パスの書き方

パスの頭に/（スラッシュ）をつけているとHerokuではエラーが出ました。ローカルではスラッシュあってもなくても問題ありませんでした。

```html
こう書くとHeroku上ではエラーが出る
"{% static '/images/xxx.png' %}"

こう書くとHeroku上でもOK
"{% static 'images/xxx.png' %}"
```

### TypeError

Heroku上では、値がNoneの変数に対して何か操作するとTypeErrorになりました（ローカルでは問題ない）。
初期値(initial)として適当な値を代入しておくことで解決できました。

```python
number = models.IntegerField(initial=0)
text = models.IntegerField(initial=".")
```

### 文字数カウント

Pythonのlen()関数で文字数をカウントできますが、ローカルとHerokuではカウントの仕方に違いがありました。ローカルでは末尾の半角スペースは無視されるのに、Herokuでは末尾のスペースもカウントされました。
たとえば、"abc " (←cの後に半角スペースが入っている)を、ローカルでは3文字として扱われ、Heroku上では4文字として扱われました。
HerokuにアップロードするときにPythonのバージョンも指定しているのになぜ...？

### オンライン実験中にサーバエラー

重めの実験プログラムだったせいか、何度も実験中のサーバエラーが発生し、泣きそうになりました。ちょっとやりすぎかなと思うくらいにはアドオンやデータベースに課金し、十分なパワーを確保しておくことが重要です([Postgres](https://devcenter.heroku.com/articles/heroku-postgresql),[Redis](https://devcenter.heroku.com/articles/heroku-redis)など)。あるいは、終了したセッションは削除したり、データベースを時々リセットすることも有用でした。

[Dyno](https://devcenter.heroku.com/articles/dyno-types)というコンテナを増やすこともHerokuに推奨されるのですが、これは実験プログラムによっては慎重になったほうがいいかもしれません（"You're only running on 1 web dyno. A second dyno will provide instant fallback if one dyno fails for any reason. Scale your dynos to 2 or more on the Resources tab."と言われた）。具体的には、初めに乱数をふるような実験プログラムででdynoを2つ以上に設定すると、実験途中で乱数が振り直されることがあることに気が付きました。Dynoの仕組みを理解しきれていないのですが、dyno切替時に変数を作り直しているのかもしれません。


# 開発していたときのバージョン
* macOS Big Sur
* otree 3.3.11
* python3.7.11