Python入門の総合演習として、油圧シリンダの理論推力計算ツールを作ります。入力・検証・計算・出力を分離し、dataclass、CSV、JSON、例外、型ヒント、unittestを統合します。
本文
Pythonの構文を個別に学んだ後は、それらを小さな道具へまとめる段階です。
今回作るのは、CSVから油圧シリンダの諸元を読み込み、押し側と引き側の推力を計算してJSONへ保存するツールです。
ただし、単に式を1行書くだけではありません。実務へ発展させやすいように、処理を次の4段階へ分けます。
- 入力:CSVから文字列を読み込む
- 検証:数値、有限性、寸法関係、効率の範囲を確認する
- 計算:SI単位へ変換して推力を求める
- 出力:計算結果をJSONへ保存する
この構成なら、将来CSVを画面入力やAPIへ変更しても、計算関数を再利用できます。
今日の到達点
この記事を読み終えると、次のことができるようになります。
- 小さなプログラムを入力・検証・計算・出力へ分割できる
dataclassで入力条件と計算結果を表現できる- CSVの文字列を検証して数値へ変換できる
- mmとMPaをSI単位へ変換して計算できる
- 例外へ行番号と原因を残せる
- 計算結果をJSONへ出力できる
- 正常値、境界値、異常値、入出力を
unittestで検証できる
ツールの入出力
入力ファイルcylinders.csvには、次の列を用意します。
| 列 | 意味 | 単位・範囲 |
|---|---|---|
name | 識別名 | 文字列 |
bore_mm | シリンダ内径 | mm、0より大きい |
rod_mm | ロッド径 | mm、0以上かつ内径未満 |
pressure_mpa | 使用圧力 | MPa、0より大きい |
efficiency | 効率 | 0より大きく1以下 |
入力例です。
name,bore_mm,rod_mm,pressure_mpa,efficiency
Cylinder A,80,40,14,0.90
Cylinder B,50,28,7,0.85
出力するJSONには、名称、押し側推力、引き側推力を保存します。
[
{
"name": "Cylinder A",
"extension_force_kn": 63.334507896370226,
"retraction_force_kn": 47.500880922277666
}
]
JSONには計算値を丸めずに保存し、画面表示だけを小数点以下2桁にします。表示用の丸めと計算用データを分けるためです。
計算式と単位
押し側では、ピストン全面が受圧します。\[ A_p=\frac{\pi D^2}{4} \]
引き側では、ピストン面積からロッド断面積を引いた環状面積が受圧します。\[ A_a=\frac{\pi(D^2-d^2)}{4} \]
推力は次の式で求めます。\[ F=pA\eta \]
- \(D\):シリンダ内径 m
- \(d\):ロッド径 m
- \(A_p\):ピストン面積 m²
- \(A_a\):環状面積 m²
- \(p\):圧力 Pa
- \(\eta\):効率、無次元
- \(F\):推力 N
CSVでは寸法をmm、圧力をMPaで受け取ります。計算直前にSI単位へ変換します。\[ D[\mathrm{m}]=\frac{D[\mathrm{mm}]}{1000} \]\[ p[\mathrm{Pa}]=p[\mathrm{MPa}]\times10^6 \]
この計算は静的な単純モデルです。実機選定では、シール摩擦、背圧、圧力損失、始動条件、座屈、取付方法、安全率などを別途検討します。
ファイル構成
最小構成は次の3ファイルです。
hydraulic_force_tool.py
test_hydraulic_force_tool.py
cylinders.csv
実行するとforce_results.jsonが生成されます。
データモデルを定義する
入力条件と結果をdataclassで表します。
from dataclasses import dataclass
import math
@dataclass(frozen=True)
class CylinderSpec:
name: str
bore_mm: float
rod_mm: float
pressure_mpa: float
efficiency: float = 1.0
def __post_init__(self) -> None:
values = (
self.bore_mm,
self.rod_mm,
self.pressure_mpa,
self.efficiency,
)
if not all(
math.isfinite(value)
for value in values
):
raise ValueError(
"all numeric values must be finite"
)
if self.bore_mm <= 0:
raise ValueError(
"bore_mm must be positive"
)
if not 0 <= self.rod_mm < self.bore_mm:
raise ValueError(
"rod_mm must satisfy "
"0 <= rod_mm < bore_mm"
)
if self.pressure_mpa <= 0:
raise ValueError(
"pressure_mpa must be positive"
)
if not 0 < self.efficiency <= 1:
raise ValueError(
"efficiency must satisfy "
"0 < efficiency <= 1"
)
@dataclass(frozen=True)
class ForceResult:
name: str
extension_force_kn: float
retraction_force_kn: float
frozen=Trueにより、作成後のフィールド代入を防ぎます。計算条件を途中で意図せず変更しにくくするためです。ただし、Pythonのすべての状態を完全に不変にする機能ではありません。
__post_init__()では型変換後の値を検証します。型ヒントだけでは、負数やNaNを自動的に拒否できません。
計算処理を独立させる
計算関数はファイルを読み書きせず、CylinderSpecを受け取ってForceResultを返します。
def calculate_forces(
spec: CylinderSpec,
) -> ForceResult:
bore_m = spec.bore_mm / 1000
rod_m = spec.rod_mm / 1000
pressure_pa = spec.pressure_mpa * 1_000_000
piston_area_m2 = math.pi * bore_m ** 2 / 4
rod_area_m2 = math.pi * rod_m ** 2 / 4
annulus_area_m2 = piston_area_m2 - rod_area_m2
extension_force_n = (
pressure_pa
* piston_area_m2
* spec.efficiency
)
retraction_force_n = (
pressure_pa
* annulus_area_m2
* spec.efficiency
)
return ForceResult(
name=spec.name,
extension_force_kn=extension_force_n / 1000,
retraction_force_kn=retraction_force_n / 1000,
)
入力元がCSVでもWebフォームでも、この計算関数は変更せずに利用できます。また、ファイルを準備しなくても数値だけでテストできます。
CSV行を検証して変換する
csv.DictReaderで読み込んだ各値は文字列です。数値へ変換し、CylinderSpecを作ります。
def spec_from_row(
row: dict[str, str],
row_no: int,
) -> CylinderSpec:
try:
return CylinderSpec(
name=row["name"].strip(),
bore_mm=float(row["bore_mm"]),
rod_mm=float(row["rod_mm"]),
pressure_mpa=float(
row["pressure_mpa"]
),
efficiency=float(
row.get("efficiency", "1.0")
),
)
except (KeyError, TypeError, ValueError) as error:
raise ValueError(
f"row {row_no}: {error}"
) from error
from errorにより、元の例外を原因として残します。たとえば3行目のbore_mmが数値でなければ、どの行で変換に失敗したか追跡できます。
CSV全体を読み込む関数です。
from pathlib import Path
import csv
def load_specs(
csv_path: Path,
) -> list[CylinderSpec]:
specs: list[CylinderSpec] = []
with csv_path.open(
"r",
encoding="utf-8",
newline="",
) as file:
reader = csv.DictReader(file)
for row_no, row in enumerate(
reader,
start=2,
):
specs.append(
spec_from_row(row, row_no)
)
return specs
start=2とするのは、1行目がヘッダーだからです。newline=""は、csvモジュールへ改行処理を任せるために指定します。
JSONへ結果を出力する
dataclasses.asdict()で結果を辞書へ変換し、json.dump()で保存します。
from dataclasses import asdict
import json
def write_results_json(
results: list[ForceResult],
json_path: Path,
) -> None:
data = [
asdict(result)
for result in results
]
with json_path.open(
"w",
encoding="utf-8",
) as file:
json.dump(
data,
file,
ensure_ascii=False,
indent=2,
)
file.write("\n")
ensure_ascii=Falseにより、日本語名を使った場合もUnicodeエスケープへ強制変換せずに保存できます。JSONの数値は文字列ではなく数値として出力されます。
処理を組み合わせる
入力、計算、出力をrun()で接続します。
def run(
csv_path: Path,
json_path: Path,
) -> list[ForceResult]:
specs = load_specs(csv_path)
results = [
calculate_forces(spec)
for spec in specs
]
write_results_json(results, json_path)
return results
スクリプトとして実行する部分です。
if __name__ == "__main__":
results = run(
Path("cylinders.csv"),
Path("force_results.json"),
)
for result in results:
print(
f"{result.name}: "
f"extension="
f"{result.extension_force_kn:.2f} kN, "
f"retraction="
f"{result.retraction_force_kn:.2f} kN"
)
実行コマンドです。
python hydraulic_force_tool.py
実行結果です。
Cylinder A: extension=63.33 kN, retraction=47.50 kN
Cylinder B: extension=11.68 kN, retraction=8.02 kN
このコードはPython 3.12.14で実行確認済みです。
unittestで計算と入出力を確認する
計算式だけでなく、検証規則とCSV→JSONの一連処理もテストします。
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import math
import unittest
from hydraulic_force_tool import (
CylinderSpec,
calculate_forces,
run,
)
class TestHydraulicForceTool(unittest.TestCase):
def test_forces_without_loss(self) -> None:
spec = CylinderSpec(
"Cylinder A",
80.0,
40.0,
14.0,
)
result = calculate_forces(spec)
expected_extension_kn = (
14_000_000
* math.pi
* 0.08 ** 2
/ 4
/ 1000
)
self.assertAlmostEqual(
result.extension_force_kn,
expected_extension_kn,
)
def test_efficiency_is_applied(self) -> None:
ideal = calculate_forces(
CylinderSpec(
"Ideal",
80.0,
40.0,
14.0,
1.0,
)
)
actual = calculate_forces(
CylinderSpec(
"Actual",
80.0,
40.0,
14.0,
0.9,
)
)
self.assertAlmostEqual(
actual.extension_force_kn,
ideal.extension_force_kn * 0.9,
)
def test_invalid_rod_diameter_raises(self) -> None:
with self.assertRaises(ValueError):
CylinderSpec(
"Invalid",
80.0,
80.0,
14.0,
)
def test_non_finite_value_raises(self) -> None:
with self.assertRaises(ValueError):
CylinderSpec(
"Invalid",
math.nan,
40.0,
14.0,
)
def test_csv_to_json(self) -> None:
with TemporaryDirectory() as directory:
csv_path = Path(directory) / "input.csv"
json_path = Path(directory) / "output.json"
csv_path.write_text(
"name,bore_mm,rod_mm,"
"pressure_mpa,efficiency\n"
"Cylinder A,80,40,14,0.9\n",
encoding="utf-8",
)
results = run(csv_path, json_path)
data = json.loads(
json_path.read_text(encoding="utf-8")
)
self.assertEqual(len(results), 1)
self.assertEqual(
data[0]["name"],
"Cylinder A",
)
if __name__ == "__main__":
unittest.main()
実行コマンドです。
python -m unittest -v test_hydraulic_force_tool.py
実行結果です。
test_csv_to_json ... ok
test_efficiency_is_applied ... ok
test_forces_without_loss ... ok
test_invalid_rod_diameter_raises ... ok
test_non_finite_value_raises ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.002s
OK
全5件をPython 3.12.14で実行し、成功を確認しました。
テストで確認していること
| 分類 | 入力・条件 | 確認内容 |
|---|---|---|
| 正常値 | 内径80 mm、ロッド40 mm、14 MPa | SI単位の式と一致する |
| 正常値 | 効率0.9 | 理論値へ0.9が反映される |
| 境界値 | ロッド径=内径 | ValueErrorになる |
| 異常値 | 内径がNaN | ValueErrorになる |
| 入出力 | CSV 1行 | JSONへ1件出力される |
境界条件をコードとテストの両方へ残すことで、後から検証規則を変更したときに影響を確認できます。
設計上のポイント
入出力と計算を分離する
calculate_forces()はCSVやJSONを知りません。そのため、将来入力元がデータベースやWeb APIになっても再利用できます。
単位を変数名へ含める
bore_mm、pressure_mpa、force_nのように単位を名前へ含め、計算内部ではSI単位へ変換しています。Pythonは物理単位の取り違えを自動検出しません。
変換前と変換後を混ぜない
CSVから得た文字列をそのまま計算へ渡さず、floatへ変換し、検証済みのCylinderSpecへまとめます。
例外を握りつぶさない
不正な行を空の結果へ置き換えるのではなく、行番号を含むValueErrorとして通知します。大量処理で不正行をスキップする場合も、行番号と理由をログや別ファイルへ残す設計が必要です。
出力丸めを計算へ戻さない
コンソールでは読みやすく2桁表示しますが、JSONには元の浮動小数点値を保存しています。表示用の丸め値で次の計算を行わないためです。
このツールの制約
このプログラムは学習用の最小モデルです。次の要素は含みません。
- シール摩擦の速度・圧力依存性
- 背圧と戻り側圧力
- 配管・弁の圧力損失
- 始動圧とスティックスリップ
- ロッド座屈
- 横荷重と取付部強度
- 動的な加減速と衝撃
- 温度による油特性の変化
- 機器仕様に基づく許容圧力判定
したがって、出力値だけで実機を選定してはいけません。ツールの責任範囲と前提条件を、計算式と一緒に管理してください。
よくある失敗
MPaとPaを混ぜる
14 MPaを14 PaとしてSI式へ入れると、結果が100万分の1になります。変数名と変換処理を明示します。
引き側でも全面積を使う
片ロッドシリンダの引き側では、ロッド断面積を差し引いた環状面積を使います。
CSVを読み込めたので安全だと思う
CSVのセルは文字列です。数値変換に成功しても、NaN、負数、寸法関係などを追加で検証する必要があります。
すべてを一つの関数へ書く
読込、検証、計算、出力を一つへ詰め込むと、計算だけをテストしにくくなります。処理の境界を関数で表現します。
テストの期待値を実装からコピーする
実装と同じ誤りをテストへコピーすると、誤った式でも成功します。重要な計算では、手計算、別式、メーカー資料など独立した根拠と照合します。
練習問題
推力差を出力する
押し側と引き側の推力差をForceResultへ追加してください。
解答例です。
@dataclass(frozen=True)
class ForceResult:
name: str
extension_force_kn: float
retraction_force_kn: float
force_difference_kn: float
calculate_forces()で次を設定します。
force_difference_kn = (
extension_force_n - retraction_force_n
) / 1000
空の名称を拒否する
nameが空文字列の場合にValueErrorを発生させてください。
解答例です。
if self.name.strip() == "":
raise ValueError("name must not be empty")
テストではCylinderSpec("", 80, 40, 14)がValueErrorになることを確認します。
無限大のテストを追加する
pressure_mpaへ無限大を渡し、拒否されることを確認してください。
解答例です。
def test_infinite_pressure_raises(self) -> None:
with self.assertRaises(ValueError):
CylinderSpec(
"Invalid",
80.0,
40.0,
math.inf,
)
まとめ
- 小さなツールでも入力・検証・計算・出力を分離する
dataclassで検証済み入力と計算結果を表現できる- CSVから読み込んだ値は文字列なので型変換が必要
- 数値へ変換できても、有限性、符号、範囲、寸法関係を検証する
- mmとMPaをmとPaへ変換し、SI単位で式を評価する
- 押し側はピストン全面積、引き側は環状面積を使う
- 例外には行番号と元の原因を残す
- JSON出力と表示用丸めを分ける
- 正常値、境界値、異常値、入出力をテストする
- 計算ツールの前提と対象外を明記する
このツールは巨大なアプリケーションではありません。しかし、入力形式を変更でき、計算を単独で再利用でき、異常値を拒否し、結果を機械可読形式で保存し、テストで変更を検証できます。
Pythonを実務のEngineering Knowledge Assetへ発展させるときに重要なのは、コード量ではなく、根拠、単位、入力条件、出力形式、検証方法が分離され、再利用できることです。

