FarmScript:農業IoTのためにDSLをゼロから設計した話
なぜ農業IoTにDSLが必要だったのか
農業IoTの自動化システムを構築するとき、エンジニアはたいてい次のどちらかを選ぶ。
- YAML/JSONベースの設定ファイル — シンプルだが、条件分岐が複雑になると破綻する
- 汎用言語(Python/Node.js)で直接実装 — 柔軟だが、農業の専門家がロジックを読めない
どちらも根本的な問題を抱えている。農業の自動化ロジックは「ドメインエキスパートが書き、エンジニアが実装する」という構造になりがちで、仕様の齟齬と保守コストが膨らむ。
FarmScriptはこの問題に対して、ドメイン特化言語(DSL)を設計するというアプローチで答えを出した。タイ・チャンタブリー県のドリアン農園を主要ユースケースとして、コンパイラをゼロから実装した事例を紹介する。
DSL設計の動機:「設定ファイル」では足りない理由
まず、なぜDSLが必要だったかを技術的に整理する。
典型的な農業IoTの自動化要件はこうなる:
- 朝5時30分に土壌水分が55%未満なら灌漑ポンプを25分間起動
- ただし開花ステージが
budding以上であれば灌漑を禁止(開花期の水やりはドリアンの致命的な収量損失につながる) - 気温が38°Cを超えたらミストシステムを起動し、LINEに通知。40°Cを超えたら同時にSMSも送信
- 無降雨14日超かつ開花ステージが
noneのときは、干ばつストレス蓄積中のアラートを送信
これをYAMLで表現しようとすると、すぐに限界が来る。
# YAMLによる試みの例 — すぐに破綻する
rules:
- condition:
and:
- sensor: soil_moisture_z1
op: lt
value: 55
- sensor: flowering_stage
op: eq
value: none
action:
type: activate
target: pump_z1
duration_minutes: 25
when A and B and not C then X else if D then Y のような複合条件が増えるにつれ、YAMLは読めなくなる。ネストが深くなり、条件の優先順位も不明瞭になる。そして何より、農業の専門家がレビューできない。
一方でPythonで直書きすれば表現力は十分だが、農園管理者が仕様を確認できる形式ではない。仕様変更のたびにエンジニアがボトルネックになる。
DSLはこのトレードオフを解消する:農業ドメインの語彙と構文を持ちながら、コンパイラが検証と変換を担う。
言語設計の方針
FarmScriptの設計で決めた主要な方針は以下のとおりだ。
宣言的ブロック構造
プログラムは意味のある単位でブロックに分割される。センサーの宣言、アクチュエータの宣言、スケジュール、ルール、アラート設定がそれぞれ独立したブロックになる。依存関係は暗黙的——ruleブロック内でセンサー名を参照すれば、コンパイラが自動的にsensorsブロックとの対応を検証する。
リアクティブなルールセマンティクス
ルールはイベント駆動ではなくポーリング駆動だ。生成されたランタイムが30秒ごとに全ルールを評価する。これはシンプルさを優先した選択で、ESP32やRaspberry Piのような組み込みターゲットでの実装を容易にする。将来的にはMQTTイベントトリガーへの移行も検討している。
型システムは最小限に
センサーの型はnumericとenumの2種類のみ。numericは比較演算子(>, <, ==, !=)をサポートし、enumは等値比較のみ許可する。型チェックはセマンティック解析フェーズで行われ、型エラーはコンパイル時に検出される。
エラーメッセージはドメイン語彙で
コンパイラエラーはエンジニア向けではなく農業の専門家向けに書かれている。これは意図的な設計判断だ。FarmScriptのファイルを書くのはエンジニアかもしれないが、レビューするのはドメインエキスパートであるべきだという考え方による。
文法の全体像
FarmScriptのプログラムは以下のブロック構造を持つ:
program
:= farm_block
sensors_block
actuators_block
alerts_block
(schedule_block | rule_block)*
各ブロックの文法を順に見ていこう。
sensors ブロック
sensors {
soil_moisture_z1: analog pin A0 range 0..100 unit "%"
soil_moisture_z2: analog pin A1 range 0..100 unit "%"
air_temperature: i2c device DHT22 unit "°C"
air_humidity: i2c device DHT22 unit "%"
rainfall: digital pin D5 unit "mm"
days_without_rain: computed from rainfall
flowering_stage: visual pin D6
values [none, budding, detected, full_bloom]
soil_temp_z1: i2c device DS18B20 unit "°C"
}
センサー宣言はプロトコル種別(analog, i2c, digital, computed, visual)、ハードウェアパラメータ(ピン番号またはデバイス名)、型情報(range指定ならnumeric、values指定ならenum)を持つ。computedは他のセンサーから派生する仮想センサーで、days_without_rainのようなステートフルな集計値に使う。
actuators ブロック
actuators {
pump_z1: relay pin D7 label "区画1 灌漑ポンプ"
pump_z2: relay pin D8 label "区画2 灌漑ポンプ"
pump_z3: relay pin D9 label "区画3 灌漑ポンプ"
pump_z4: relay pin D10 label "区画4 灌漑ポンプ"
all_pumps: group [pump_z1, pump_z2, pump_z3, pump_z4]
mist_system: relay pin D11
fertigation: relay pin D12
}
groupは複数アクチュエータへの一括命令を可能にするシンタックスシュガーだ。コード生成時に展開される。
rule ブロック
ルールはwhen節の集合で、各when節は条件式と命令列を持つ。
rule flowering_control {
when days_without_rain > 14 and flowering_stage == none {
alert "無降雨{days_without_rain}日目 — 開花サインを監視" via line
log "干ばつストレス蓄積中。Z1:{soil_moisture_z1}% Z2:{soil_moisture_z2}%"
}
when flowering_stage == budding {
turn off all_pumps
alert "開花(蕾)検知 — 全灌漑停止。灌漑厳禁。"
via line priority high
alert "開花検知 — 全灌漑停止"
via sms priority high
}
when flowering_stage == full_bloom {
turn off all_pumps
alert "満開確認。灌漑継続停止。4〜6日以内に結実見込み。" via line
}
}
rule heat_stress {
when air_temperature > 38 {
turn on mist_system
alert "熱ストレス: {air_temperature}°C — ミスト起動" via line
}
when air_temperature > 40 {
turn on mist_system
alert "危険高温: {air_temperature}°C — 落果リスク。直ちに確認。"
via line priority high
alert "危険高温 {air_temperature}°C" via sms priority high
}
when air_temperature < 34 {
turn off mist_system
}
}
条件式の文法:
condition := expr (("and" | "or") expr)*
expr := sensor_ref op literal
op := ">" | "<" | ">=" | "<=" | "==" | "!="
literal := NUMBER | IDENTIFIER # NUMBERはnumeric型、IDENTIFIERはenum値
andとorの優先順位は左結合。括弧による明示的なグルーピングは現バージョンでは未サポート(計画中)。
コンパイラの実装
コンパイラはPythonで実装されており、4つのフェーズで構成される。
フェーズ1:レクサー(字句解析)
レクサーは手書きのステートマシンで実装した。正規表現ベースのレクサーに比べてエラーメッセージの制御がしやすく、農業語彙のキーワード認識も柔軟に対応できる。
class Lexer:
KEYWORDS = {
'farm', 'sensors', 'actuators', 'alerts', 'schedule', 'rule',
'when', 'and', 'or', 'turn', 'on', 'off', 'for', 'alert',
'log', 'report', 'via', 'at', 'daily', 'on', 'computed', 'from',
'group', 'range', 'unit', 'values', 'priority', 'high'
}
def tokenize(self, source: str) -> list[Token]:
tokens = []
pos = 0
while pos < len(source):
# スキップ: 空白・コメント
if source[pos] in ' \t\n\r':
pos += 1
continue
if source[pos:pos+1] == '#':
while pos < len(source) and source[pos] != '\n':
pos += 1
continue
# 文字列リテラル
if source[pos] == '"':
end = source.index('"', pos + 1)
tokens.append(Token(TokenType.STRING, source[pos+1:end]))
pos = end + 1
continue
# 識別子 / キーワード
if source[pos].isalpha() or source[pos] == '_':
start = pos
while pos < len(source) and (source[pos].isalnum() or source[pos] == '_'):
pos += 1
word = source[start:pos]
tt = TokenType.KEYWORD if word in self.KEYWORDS else TokenType.IDENTIFIER
tokens.append(Token(tt, word))
continue
# 数値
if source[pos].isdigit():
start = pos
while pos < len(source) and (source[pos].isdigit() or source[pos] == '.'):
pos += 1
tokens.append(Token(TokenType.NUMBER, float(source[start:pos])))
continue
# 演算子・区切り文字
two_char = source[pos:pos+2]
if two_char in ('>=', '<=', '==', '!=', '..'):
tokens.append(Token(TokenType.OPERATOR, two_char))
pos += 2
continue
tokens.append(Token(TokenType.SYMBOL, source[pos]))
pos += 1
return tokens
フェーズ2:パーサー(構文解析)
再帰降下パーサーで実装した。各ブロック種別に対応するパース関数があり、それらが相互に呼び出す構造になっている。
class Parser:
def __init__(self, tokens: list[Token]):
self.tokens = tokens
self.pos = 0
def parse_program(self) -> ProgramNode:
nodes = []
while not self.at_end():
if self.peek_keyword('farm'):
nodes.append(self.parse_farm_block())
elif self.peek_keyword('sensors'):
nodes.append(self.parse_sensors_block())
elif self.peek_keyword('actuators'):
nodes.append(self.parse_actuators_block())
elif self.peek_keyword('alerts'):
nodes.append(self.parse_alerts_block())
elif self.peek_keyword('schedule'):
nodes.append(self.parse_schedule_block())
elif self.peek_keyword('rule'):
nodes.append(self.parse_rule_block())
else:
raise ParseError(f"予期しないトークン: {self.current()}")
return ProgramNode(nodes)
def parse_rule_block(self) -> RuleNode:
self.expect_keyword('rule')
name = self.expect(TokenType.IDENTIFIER).value
self.expect_symbol('{')
clauses = []
while not self.peek_symbol('}'):
self.expect_keyword('when')
condition = self.parse_condition()
self.expect_symbol('{')
body = self.parse_command_list()
self.expect_symbol('}')
clauses.append(WhenClause(condition, body))
self.expect_symbol('}')
return RuleNode(name, clauses)
def parse_condition(self) -> ConditionNode:
left = self.parse_expr()
while self.peek_keyword('and') or self.peek_keyword('or'):
op = self.advance().value
right = self.parse_expr()
left = BinaryCondition(left, op, right)
return left
def parse_expr(self) -> ExprNode:
sensor = self.expect(TokenType.IDENTIFIER).value
op = self.expect(TokenType.OPERATOR).value
if self.peek(TokenType.NUMBER):
value = self.advance().value
return CompareExpr(SensorRef(sensor), op, NumericLiteral(value))
else:
value = self.advance().value
return CompareExpr(SensorRef(sensor), op, EnumLiteral(value))
フェーズ3:セマンティック解析
セマンティック解析では以下の検証を行う:
class SemanticAnalyzer:
def __init__(self, program: ProgramNode):
self.program = program
self.sensor_types: dict[str, SensorType] = {}
self.actuator_names: set[str] = set()
self.alert_channels: set[str] = set()
self.errors: list[SemanticError] = []
def analyze(self) -> bool:
self._collect_declarations()
self._validate_rules()
return len(self.errors) == 0
def _collect_declarations(self):
for node in self.program.nodes:
if isinstance(node, SensorsBlock):
for decl in node.declarations:
if decl.values: # enum型
self.sensor_types[decl.name] = EnumSensorType(decl.values)
else: # numeric型
self.sensor_types[decl.name] = NumericSensorType(decl.range)
elif isinstance(node, ActuatorsBlock):
for decl in node.declarations:
self.actuator_names.add(decl.name)
if isinstance(decl, GroupDecl):
self.actuator_names.add(decl.name)
elif isinstance(node, AlertsBlock):
for channel in node.channels:
self.alert_channels.add(channel.type)
def _validate_expr(self, expr: ExprNode, line: int):
sensor_name = expr.left.name
if sensor_name not in self.sensor_types:
# タイプミス候補を提案
candidates = difflib.get_close_matches(
sensor_name, self.sensor_types.keys(), n=1
)
suggestion = f" もしかして: '{candidates[0]}' ですか?" if candidates else ""
self.errors.append(SemanticError(
line,
f"センサー '{sensor_name}' が宣言されていません。\n{suggestion}"
))
return
sensor_type = self.sensor_types[sensor_name]
if isinstance(sensor_type, EnumSensorType):
if expr.op not in ('==', '!='):
self.errors.append(SemanticError(
line,
f"enum型センサー '{sensor_name}' には '==' か '!=' のみ使用できます。"
))
if isinstance(expr.right, NumericLiteral):
self.errors.append(SemanticError(
line,
f"'{sensor_name}' はenum型です。数値との比較はできません。"
f"有効な値: {sensor_type.values}"
))
elif expr.right.value not in sensor_type.values:
self.errors.append(SemanticError(
line,
f"'{expr.right.value}' は '{sensor_name}' の有効な値ではありません。"
f"有効な値: {sensor_type.values}"
))
フェーズ4:コード生成
ASTを辿ってPythonモジュールを生成する。生成コードはクリーンで読みやすく、手で書いたかのような品質を目標にしている。
class CodeGenerator:
def generate_rule(self, rule: RuleNode) -> str:
lines = [
f"# ルール: {rule.name}",
f"# FarmScript v1.0 生成 — 手動編集禁止",
f"def rule_{rule.name}():",
]
# ルール内で使われるセンサーを収集してread文を生成
sensors_used = self._collect_sensors(rule)
for sensor in sorted(sensors_used):
lines.append(f" {sensor} = sensors.read('{sensor}')")
lines.append("")
for i, clause in enumerate(rule.clauses):
keyword = "if" if i == 0 else "elif"
condition_str = self._generate_condition(clause.condition)
lines.append(f" {keyword} {condition_str}:")
for cmd in clause.body:
lines.extend(self._generate_command(cmd, indent=8))
lines.append("")
return "\n".join(lines)
def _generate_condition(self, cond: ConditionNode) -> str:
if isinstance(cond, CompareExpr):
sensor = cond.left.name
op = cond.op
if isinstance(cond.right, EnumLiteral):
return f'{sensor} == "{cond.right.value}"'
else:
return f"{sensor} {op} {cond.right.value}"
elif isinstance(cond, BinaryCondition):
left = self._generate_condition(cond.left)
right = self._generate_condition(cond.right)
return f"({left} {cond.op} {right})"
開花制御ルールの生成結果:
# ルール: flowering_control
# FarmScript v1.0 生成 — 手動編集禁止
def rule_flowering_control():
days_without_rain = sensors.read('days_without_rain')
flowering_stage = sensors.read('flowering_stage')
soil_moisture_z1 = sensors.read('soil_moisture_z1')
soil_moisture_z2 = sensors.read('soil_moisture_z2')
if days_without_rain > 14 and flowering_stage == "none":
alerts.line(
f"無降雨{days_without_rain}日目 — 開花サインを監視"
)
log(f"干ばつストレス蓄積中。Z1:{soil_moisture_z1}% Z2:{soil_moisture_z2}%")
elif flowering_stage == "budding":
actuators.group_off("all_pumps")
alerts.line(
"開花(蕾)検知 — 全灌漑停止。灌漑厳禁。",
priority="high"
)
alerts.sms("開花検知 — 全灌漑停止", priority="high")
elif flowering_stage == "full_bloom":
actuators.group_off("all_pumps")
alerts.line("満開確認。灌漑継続停止。4〜6日以内に結実見込み。")
ランタイムアーキテクチャ
生成されたPythonコードはfarmscript_rtランタイムライブラリに依存する。このライブラリはハードウェア抽象化レイヤーを提供し、生成コードがセンサーの物理プロトコルやアクチュエータの制御方法を意識しなくて済む設計になっている。
┌─────────────────────────────────┐
│ generated/main.py │ ← FarmScriptが生成
│ rule_flowering_control() │
│ rule_heat_stress() │
│ rule_irrigation() │
└──────────────┬──────────────────┘
│ imports
┌──────────────▼──────────────────┐
│ farmscript_rt/ │ ← ランタイムライブラリ
│ SensorManager (I2C/GPIO) │
│ ActuatorManager (Relay/PWM) │
│ AlertManager (LINE/SMS) │
│ TimerManager (async tasks) │
│ LogManager (JSON/file) │
└─────────────────────────────────┘
│ hardware calls
┌──────────────▼──────────────────┐
│ Raspberry Pi 4 / ESP32 │
│ GPIO, I2C, 4G modem │
└─────────────────────────────────┘
ポーリングループ:
# generated/main.py
import time
import signal
import logging
from rules import rule_flowering_control, rule_heat_stress, rule_irrigation
from farmscript_rt import RuntimeContext
ctx = RuntimeContext.from_config("farm_config.json")
def main():
logging.info("FarmScript runtime 起動")
while True:
try:
rule_flowering_control()
rule_heat_stress()
rule_irrigation()
except SensorReadError as e:
logging.error(f"センサー読み取りエラー: {e}")
ctx.alerts.line(f"センサーエラー: {e}", priority="high")
except Exception as e:
logging.critical(f"予期しないエラー: {e}", exc_info=True)
time.sleep(ctx.poll_interval_seconds) # デフォルト30秒
if __name__ == "__main__":
signal.signal(signal.SIGTERM, lambda *_: ctx.shutdown())
main()
設計上のトレードオフと今後の課題
ポーリング vs イベント駆動
現在の実装はポーリングベースで、30秒ごとに全ルールを評価する。シンプルで実装が容易だが、高頻度センサー(例:雨量センサーの立ち上がりエッジ)には不向きだ。次バージョンではMQTTイベントブリッジを追加し、ポーリングとイベント駆動のハイブリッドにする予定だ。
条件式の表現力
現在、括弧によるグルーピングがサポートされていない。when A and B or Cは(A and B) or Cとして左結合で評価されるが、A and (B or C)は書けない。ユーザーには別のwhen節に分割することで回避してもらっている。パーサーへの括弧サポート追加は実装コストが低く、次期リリースに含める。
タイマー処理
turn on pump_z1 for 25 minutesのようなタイマー付き命令は、TimerManagerが非同期で管理する。競合状態(例:タイマー実行中に別のルールが同じアクチュエータを操作する)の検出はランタイム側でロックを使って対処しているが、FarmScriptレベルで静的に検出できるようにしたい。コンパイル時のアクチュエータ競合解析は今後の課題だ。
マルチターゲットコンパイル
現在はCPython(Raspberry Pi)のみをターゲットとしているが、MicroPython(ESP32)へのコンパイルも計画している。コード生成器をターゲット別に分離するプラグインアーキテクチャに移行中だ。
まとめ
FarmScriptの設計と実装を通じて得た主な知見:
- DSLの有効性は「どこで複雑さを管理するか」の問題だ。 YAMLや汎用言語で表現しにくい複合条件がドメインに存在するなら、DSLは投資に値する。
- 再帰降下パーサーは実装コストが低く、農業のような中程度の複雑さのDSLに適している。 パーサージェネレータ(ANTLR等)は表現力が高いが、エラーメッセージのカスタマイズが難しい。
- セマンティック解析でドメインルールを強制することが、DSLの信頼性の核心だ。 型チェックだけでなく、ドメイン固有の制約(開花期には灌漑不可など)をコンパイル時に検出できる仕組みを将来的に組み込みたい。
- 生成コードの可読性は重要だ。 農業の専門家がデプロイ後の動作を確認できるよう、生成Pythonは手書きと見分けがつかない品質を目標にしている。
Simplico Co., Ltd. はバンコクを拠点とするソフトウェアエンジニアリング&プロダクトスタジオです。IoT自動化システム、AI/RAGアプリケーション、エンタープライズインテグレーションを専門としています。技術的なご質問やコラボレーションのご相談は simplico.net よりお気軽にどうぞ。
タグ: DSL設計 コンパイラ 農業IoT Python Raspberry Pi 再帰降下パーサー FarmScript MicroPython スマート農業
Get in Touch with us
Related Posts
- スマート農業プロジェクトがパイロット段階を脱せずに終わる理由
- ERPプロジェクトが予算オーバー・納期遅延・期待外れに終わる理由
- 耐障害性ドローン群設計:セキュア通信を備えたリーダーレス・トレラント・メッシュネットワーク
- NumPyブロードキャストの法則:`(3,)` と `(3,1)` の動作が異なる理由 ― そして「警告なしに間違った答えを返す」場面とは
- 重要インフラへの攻撃:ウクライナ電力網から学ぶIT/OTセキュリティの教訓
- LM Studioのコーディング向けシステムプロンプト設計:`temperature`・`context_length`・`stop`トークン徹底解説
- LlamaIndex + pgvector:日本語・タイ語ビジネス文書に対応したRAGの本番運用
- simpliShop:受注生産・多言語対応のタイ向けECプラットフォーム
- ERPプロジェクトが失敗する理由と成功のための実践的アプローチ
- Payment APIにおけるIdempotencyとは何か
- Agentic AI × SOCワークフロー:プレイブックを超えた自律防御【2026年版ガイド】
- SOCをゼロから構築する:Wazuh + IRIS-web 現場レポート
- ECと基幹システムの二重入力をなくす:受注から仕訳までの自動化アーキテクチャ
- SIerのブラックボックスから脱却する:オープンソースで構築する中小企業向けSOCアーキテクチャ
- リサイクル工場管理システム:日本のリサイクル事業者が見えないところで損をしている理由
- エネルギー管理ソフトウェアのROI:電気代を15〜40%削減できる理由
- Wazuh + オープンソースで構築する軽量SOC:実践ガイド(2026年版)
- ECサイトとERPを正しく連携する方法:実践ガイド(2026年版)
- AI コーディングアシスタントが実際に使うツールとは?(Claude Code・Codex CLI・Aider)
- 燃費を本気で改善する:高負荷・低回転走行の物理学













