【Python】ディシジョンテーブルを作成し、Excelに出力するコード

今回は、どれだけのケースを想定してテストを行うかを一覧化したいときに使用される決定表（Decision Table）をPythonで作成するといったものです。
個人的には、以前作成したコードのリニューアルといった意味合いが強いです。

コード内でインストールが必要なものは `openpyxl` のみですので、インストールされていない方は

```python:こちらを実行
pip install openpyxl
```

を実行してインストールしてください。それ以外はPythonの標準モジュールを使用しているので別途インストールは不要です。

この記事を作成するにあたり参考にしたのは以下の通り：

* [Python でリストをフラット化する方法](https://www.delftstack.com/ja/howto/python/how-to-flatten-a-list-in-python/)
* [Pythonのdequeでキュー、スタック、デック（両端キュー）を扱う](https://note.nkmk.me/python-collections-deque/)
* [How to Replace Values in a List in Python?](https://www.geeksforgeeks.org/how-to-replace-values-in-a-list-in-python/)
* [openpyxl公式ドキュメント](https://openpyxl.readthedocs.io/en/stable/index.html)
* [itertools公式ドキュメント](https://docs.python.org/ja/3/library/itertools.html)


# 0. 導入

今回の説明の中では次のようなJSONファイルを考えます：

```json: sample.json
{
    "Cond-1": ["YES", "NO"],
    "Cond-2": ["TRUE", "FALSE"],
    "Cond-3": ["HIGH", "LOW"]
}
```

※使用するJSONファイルの中身は `{ key: [value1, value2], ... }` としています。 形式は扱いやすそうだ、という点から決定。

各条件の条件値の数はそれぞれ2通りになっているので、すべての組み合わせは `2*2*2 = 8通り` となります。この場合のディシジョンテーブルは次の通り。 

| # | Cond-1 | Cond-2 | Cond-3 |
|---|--------|--------|--------|
| 1 | YES    | TRUE   | HIGH   |
| 2 | YES    | TRUE   | LOW    |
| 3 | YES    | FALSE  | HIGH   |
| 4 | YES    | FALSE  | LOW    |
| 5 | NO     | TRUE   | HIGH   |
| 6 | NO     | TRUE   | LOW    |
| 7 | NO     | FALSE  | HIGH   |
| 8 | NO     | FALSE  | LOW    |

※一般的なディシジョンテーブルでは条件とケースが縦横逆かもですが、扱いやすいのはこの形なのでこのままでいきます。Excelに出力されたデータに対して転置を掛ければ同じなのでそこは気にせずでということで。

このくらいの数であれば書き下すことにコードは不要かもしれませんが、条件が5つで条件値がそれぞれ2つあるとすれば `2^5=32通り` のようにケースは倍以上の数だけ増えていくのでミスなく書き下すことは難しくなっていきます。（もちろん、条件値が2つだけとは限らないので。）

そこで、コードに任せて書き下してもらおうというのが動機となります。

# 1. コード概要

* JSONファイルを読み込む
* JSONデータから「条件」とその「条件値」を取得
* 異なる「条件」からすべての組み合わせ（ケース）の一覧を作成
* 「条件」とケースの一覧をExcelに出力

# 2. コード詳細

実際のコードを紹介する前に、少し複雑となってしまった点について説明。

## 2-1. 組み合わせの作成

以前はディシジョンテーブルを作成するために、 `itertools.product` を用いて条件を作成していました。この関数は[こちら](https://docs.python.org/ja/3/library/itertools.html#itertools.product)の記事にもある通り `product(A, B)` は次のコードとおよそ等価：

```python: product関数とほぼ等価なコード
((x,y) for x in A for y in B) # ジェネレータ式
```

この関数を利用すれば、例えば

```python
from itertools import product

A = ["YES", "NO"]
B = ["TRUE", "FALSE"]
C = ["HIGH", "LOW"]

for case in product(A,B,C):
    print(case)

# 出力
# ('YES', 'TRUE', 'HIGH')
# ('YES', 'TRUE', 'LOW')
# ('YES', 'FALSE', 'HIGH')
# ('YES', 'FALSE', 'LOW')
# ('NO', 'TRUE', 'HIGH')
# ('NO', 'TRUE', 'LOW')
# ('NO', 'FALSE', 'HIGH')
# ('NO', 'FALSE', 'LOW')
```

により目的のディシジョンテーブルは作成が可能です。これを `for文` で再現すると

```python
for x in A:
    for y in B:
        for z in C:
            print(x,y,z)
```

となるかと思います。（形式には目をつぶりまして。）

ここで問題なのが、関数の引数をいくつ渡すかを制御することが面倒で、コードも冗長になってしまう。そこで今回ご紹介する関数です。この関数では引数の数には依らずケースのリストを返すことが可能です。
改善の結果作成した実際のコードがこちら：

```python
def case_list(origin:list,cases:list) -> list:
    if not cases:
        result = origin
        return result
    else:
        arguments = deque(cases)
        if not origin:
            result = arguments.popleft()
        else:
            result = []
            for o in origin:
                for arg in arguments[0]:
                    result.append([item for l in [o, arg] for item in l])
            arguments.popleft()
        return case_list(result, list(arguments))

values=[["Y", "N"],["T", "F"],["H", "L"]]
cases = case_list([], values)
print(cases)
# 出力
# [['Y', 'T', 'H'], ['Y', 'T', 'L'], ['Y', 'F', 'H'], ['Y', 'F', 'L'], ['N', 'T', 'H'], ['N', 'T', 'L'], ['N', 'F', 'H'], ['N', 'F', 'L']]
```

関数 `case_list` は2つの引数をとり、1つ目はリスト（※現状は空のリストを渡すだけ）、2つ目は条件値をまとめたリストを渡す。関数内では `collections.deque` を使用しているのでimport部分が必要という点に注意。

基本的には2つ目のリストを左から順に処理していって、「先頭の要素→処理→削除→次の要素→ ... →最後の要素→処理→削除→リスト（空）」のようにリスト要素がなくなったら処理が終了します。この先頭要素を削除を行うために `collections.deque` を使用しました。
ただし、このコードには問題点があり、それは上記サンプルコードにある「`values`」の値にあります。ざっくり説明すると `string` が `iterable` であるため条件値が2文字以上となると思った結果が得られないというものです。その原因となるコードが

```python
result.append([item for l in [o, arg] for item in l])
```

です。このコードは「[Python でリストをフラット化する方法](https://www.delftstack.com/ja/howto/python/how-to-flatten-a-list-in-python/)」から拝借した箇所で、例えば `[[x, y],z]`というリストを `[x,y,z]`のように「フラット化」する意図で記載しています。この「フラット化」の副作用として

```python
[print(item) for i in [["TRUE", "FALSE"], "HIGH"] for item in i]
```

のようにすると、前のリストと文字列がfor文で回されて出力が

```python
TRUE
FALSE
H
I
G
H
```

のようになってしまいます。
これを避けるために、 関数`case_list`の第2引数のリストの文字列の接頭辞のみを受け取るように処理を付加。（次の節で説明。）

## 2-2. 文字列の置換

節2-1で生じた問題に対応するために文字列の置換を行いました。概要としては`TRUE`、`FALSE`といった文字列を`T`、`F`に変換し、関数`case_list`に渡す。その戻り値に対して`T→TRUE`、`F→FALSE`のように再度置換を行うことでもとに戻す。
このとき、注意すべきケースとしては`TRUE`と`TOP`が存在する場合です。この場合は`TRUE`の`T`が先勝ちして、`TOP`は別の1文字で置換するといった処理を施しました。以上のことを考慮したコードが次の通り：

```python
import string

def key_list(values:list, reverse=False) -> dict:
    char_dict = {}
    for value in values:
        for v in value:
            if v[0] not in char_dict.keys():
                char_dict.update({ v[0]:v }) if not reverse else char_dict.update({ v:v[0] }) 
            elif v[0] in char_dict.keys() and v not in char_dict.values(): # keyは重複したがvalueは重複していない
                for key in string.ascii_uppercase: # keyに使用していない文字をkeyとして登録
                    if key not in char_dict.keys():
                        char_dict.update({ key:v }) if not reverse else char_dict.update({ v:key })
                        break
    return char_dict


def value_to_key(cases:list, keys:dict) -> list:
    tmp_list = []
    for case in cases:
        tmp_item = case
        for key, value in keys.items():
            tmp_item = list(map(lambda x: x.replace(value, key) if x == value else x, tmp_item))
        tmp_list.append(tmp_item)
    return tmp_list


def key_to_value(cases:list, keys:dict) -> list:
    tmp_list = []
    for case in cases:
        tmp_item = case
        for key, value in keys.items():
            tmp_item = list(map(lambda x: x.replace(key, value) if x == key else x, tmp_item))
        tmp_list.append(tmp_item)
    return tmp_list

values=[["YES", "NO"],["TRUE", "FALSE"],["HIGH", "LOW"], ["TOP", "BOTTOM"]]
keys = key_list(values)
repl_values = value_to_key(values,keys)
repl_cases = key_to_value(repl_values,keys)
print(keys)
print(repl_values)
print(repl_cases)
```

※ `TRUE`と`TOP`の比較ができるように今回の例だけ、`["TOP", "BOTTOM"]`を追加しています。

こちらを実行すると以下のような出力が得られるかと思います：

```python
{'Y': 'YES', 'N': 'NO', 'T': 'TRUE', 'F': 'FALSE', 'H': 'HIGH', 'L': 'LOW', 'A': 'TOP', 'B': 'BOTTOM'}
[['Y', 'N'], ['T', 'F'], ['H', 'L'], ['A', 'B']]
[['YES', 'NO'], ['TRUE', 'FALSE'], ['HIGH', 'LOW'], ['TOP', 'BOTTOM']]
```

関数`key_list`内で `string.ascii_uppercase` を使用していますが`1,2,...,a,b,...`のような別文字を代替文字列として使用するように `string.ascii_letters`や `string.digits` などで拡張も可能です。


# 3. 実際のコード

JSON形式のファイルを読み取り、Excelファイルに出力するまでのコードが以下のようになります。

```python: main.py
import os
import json
import string
from datetime import datetime, timedelta, timezone
from collections import deque
from tkinter import Tk
from tkinter import filedialog
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
from openpyxl.styles import Font
from openpyxl.worksheet.table import Table, TableStyleInfo


def case_list(origin:list,cases:list) -> list:
    if not cases:
        result = origin
        return result
    else:
        arguments = deque(cases)
        if not origin:
            result = arguments.popleft()
        else:
            result = []
            for o in origin:
                for arg in arguments[0]:
                    result.append([item for l in [o, arg] for item in l])
            arguments.popleft()
        return case_list(result, list(arguments))


def key_list(values:list, reverse=False) -> dict:
    char_dict = {}
    for value in values:
        for v in value:
            if v[0] not in char_dict.keys():
                char_dict.update({ v[0]:v }) if not reverse else char_dict.update({ v:v[0] }) 
            elif v[0] in char_dict.keys() and v not in char_dict.values(): # keyは重複したがvalueは重複していない
                for key in string.ascii_uppercase: # keyに使用していない文字をkeyとして登録
                    if key not in char_dict.keys():
                        char_dict.update({ key:v }) if not reverse else char_dict.update({ v:key })
                        break
    return char_dict


def value_to_key(cases:list, keys:dict) -> list:
    tmp_list = []
    for case in cases:
        tmp_item = case
        for key, value in keys.items():
            tmp_item = list(map(lambda x: x.replace(value, key) if x == value else x, tmp_item))
        tmp_list.append(tmp_item)
    return tmp_list


def key_to_value(cases:list, keys:dict) -> list:
    tmp_list = []
    for case in cases:
        tmp_item = case
        for key, value in keys.items():
            tmp_item = list(map(lambda x: x.replace(key, value) if x == key else x, tmp_item))
        tmp_list.append(tmp_item)
    return tmp_list


current_directory = os.path.dirname(__file__)
current_datetime = datetime.now(timezone(timedelta(hours=9), 'JST')).strftime('%Y%m%d%H%M%S')

# Tkinterの設定
root = Tk()
root.geometry("0x0") # windowサイズを0
root.overrideredirect(1) # windowタイトルバーを消す
file_types = (
    ("json file", "*.json"),
)
selected_file = filedialog.askopenfilename(initialdir=current_directory, filetypes=file_types)

# Jsonファイル読み取り
with open(selected_file,"r", encoding="utf-8") as f:
    read_cases = json.load(f)

# ヘッダーとケース
header = [str(key) for key in read_cases.keys()]
header.insert(0,"#")
values = [value for value in read_cases.values()]
char_dict = key_list(values)

# caseの値を１文字に置換する
repl_values = value_to_key(values, char_dict)

# Caseの作成
cases = case_list([], repl_values)

# caseの値を１文字から元の文字列に置換する
repl_cases = key_to_value(cases, char_dict)

# Excelの設定
wb = Workbook()
ws = wb.active
ws.title = "Decision Table"
wsprops = ws.sheet_properties
wsprops.tabColor = "1072BA"
ws.sheet_view.zoom = 100 # set 100% zoom

ws.append(header) # ヘッダーは必ずstringであること。デフォルトでは1行目がヘッダーになる

for row in repl_cases:
    row.insert(0,"=ROW()-1") # Case番号を追加
    ws.append(row)

# テーブルの定義
max_row_index = ws.max_row
max_col_index = ws.max_column
max_col_letter = get_column_letter(max_col_index)
tab = Table(displayName="DecisionTable1", ref=f"A1:{max_col_letter}{max_row_index}")
ws.add_table(tab) # table must be addes using ws.add_table() method to avoid duplicate names = table name is unique

# テーブルスタイルの設定
style = TableStyleInfo(
    name="TableStyleMedium9",
    showFirstColumn=False,
    showLastColumn=False,
    showRowStripes=True, # 縞模様
    showColumnStripes=False
)
tab.tableStyleInfo = style

# セルスタイルの設定（フォントはセル単位で設定する）
font = Font(
    name="游ゴシック",
    size=12,
    bold=False,
    italic=False,
    vertAlign=None,
    underline=None,
    strike=False,
    color="00000000"
)
for row_index in range(max_row_index):
    for col_index in range(max_col_index):
        ws.cell(row=row_index+1, column=col_index+1).font = font

# Excelファイルの保存
wb.save(f"DecisionTable_{current_datetime}.xlsx")
```

# 4. まとめ

今回の記事で、過去の清算？ができたかと考えています。いろいろ使いたい要素をつぎ込んでいったので同じような機能を実装したい場合に、似たコードを作成することはないなと思いました。（その場で作るのは面白いが統一感はないかも？）
入口のJSONファイルはコマンドで入力してその入力値をもとに作成するという方法もありかとは思いましたが、面倒そうだったので止めましたw。
そこまでディシジョンテーブルを作成する機会はないですが、面白かったので良しとします。