{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 容量约束设施选址问题(CFLP)\n",
    "\n",
    "**类别：** 选址\n",
    "\n",
    "使用 OptAgent 的 Python 接口描述变量、约束与目标。\n",
    "\n",
    "问题与原始示例来源：[Hexaly Code Templates](https://www.hexaly.com/templates/capacitated-facility-location-problem-cflp)。\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 问题描述\n",
    "\n",
    "**在容量约束设施选址问题(Capacitated Facility Location Problem, CFLP)**中,若干具有已知需求的客户点必须被分配给可用的设施。分配给每个设施的客户点总需求不得超过其容量。每个设施都有一个固定的开启费用,只要该设施至少服务一个客户点就必须支付。目标是最小化总成本,该成本为所有已开启设施的开启费用与所有客户点的分配费用之和。\n",
    "\n",
    "### 学习要点\n",
    "\n",
    "- 添加 集合决策变量 以建模分配给每个设施的客户点\n",
    "- 使用 lambda 表达式 计算每个设施的总需求与总费用\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 数据\n",
    "\n",
    "所提供的数据文件来自 [OR-LIB](http://people.brunel.ac.uk/~mastjjb/jeb/orlib/pmedcapinfo.html)。数据格式如下:\n",
    "\n",
    "- 第一行:候选设施数量与客户点数量\n",
    "- 每个设施的容量与开启费用\n",
    "- 每个客户点的需求\n",
    "- 每个设施与每个客户点之间的分配费用\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 建模思路\n",
    "\n",
    "容量约束设施选址问题(CFLP)的 OptAgent 模型使用 集合决策变量 来表示分配给每个设施的客户点。借助 partition 算子,我们确保每个客户点恰好被分配到一个设施。\n",
    "\n",
    "我们可以使用需求数组上的 at 算子来访问序列中每个客户点的需求。每个设施所服务的总需求通过一个 lambda 函数 计算,该函数将 sum 算子应用于所有关联客户点的需求。该总需求必须不超过该设施的容量。\n",
    "\n",
    "类似地,我们将每个设施的分配费用计算为其所服务的所有客户点的分配价格之和。使用 count 算子,我们检查每个设施是否至少服务一个客户点。如果是,则还需支付该设施的开启费用。\n",
    "\n",
    "目标函数为所有设施的开启费用与分配费用之和。\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Python 实现\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cflp-model",
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "from optagent import OptModel, solve\n",
    "\n",
    "\n",
    "\n",
    "\n",
    "def read_data(filename):\n",
    "    \"\"\"Read a CFLP instance in the OR-LIB format.\"\"\"\n",
    "    with open(filename, encoding=\"utf-8\") as f:\n",
    "        tokens = f.read().split()\n",
    "    it = iter(tokens)\n",
    "    nb_max_facilities = int(next(it))\n",
    "    nb_sites = int(next(it))\n",
    "    capacity_data = []\n",
    "    opening_price_data = []\n",
    "    for _ in range(nb_max_facilities):\n",
    "        capacity_data.append(float(next(it)))\n",
    "        opening_price_data.append(float(next(it)))\n",
    "    demand_data = [float(next(it)) for _ in range(nb_sites)]\n",
    "    allocation_price_data = [\n",
    "        [float(next(it)) for _ in range(nb_sites)] for _ in range(nb_max_facilities)\n",
    "    ]\n",
    "    return (\n",
    "        nb_max_facilities,\n",
    "        nb_sites,\n",
    "        capacity_data,\n",
    "        opening_price_data,\n",
    "        demand_data,\n",
    "        allocation_price_data,\n",
    "    )\n",
    "\n",
    "\n",
    "def main(instance_file, output_file=None, time_limit=10):\n",
    "    (\n",
    "        nb_max_facilities,\n",
    "        nb_sites,\n",
    "        capacity_data,\n",
    "        opening_price_data,\n",
    "        demand_data,\n",
    "        allocation_price_data,\n",
    "    ) = read_data(instance_file)\n",
    "\n",
    "    model = OptModel()\n",
    "    # Each facility assignment is the set of sites served by that facility.\n",
    "    facility_assignments = [model.set(nb_sites) for _ in range(nb_max_facilities)]\n",
    "    # Every site is served by exactly one facility.\n",
    "    model.constraint(model.partition(facility_assignments))\n",
    "\n",
    "    demand = model.array(demand_data)\n",
    "    allocation_price = model.array(allocation_price_data)\n",
    "\n",
    "    cost = []\n",
    "    for f in range(nb_max_facilities):\n",
    "        facility = facility_assignments[f]\n",
    "        size = model.count(facility)\n",
    "\n",
    "        # Capacity constraint: total demand served by this facility.\n",
    "        demand_lambda = model.lambda_function(lambda i: demand[i])\n",
    "        model.constraint(model.sum(facility, demand_lambda) <= capacity_data[f])\n",
    "\n",
    "        # Allocation cost + opening cost when the facility serves at least one site.\n",
    "        cost_selector = model.lambda_function(\n",
    "            lambda i: model.at(allocation_price, f, i)\n",
    "        )\n",
    "        cost.append(\n",
    "            model.sum(facility, cost_selector) + opening_price_data[f] * (size > 0)\n",
    "        )\n",
    "\n",
    "    total_cost = model.sum(*cost)\n",
    "    model.minimize(total_cost)\n",
    "\n",
    "    solution = solve(model, time_limit_s=float(time_limit))\n",
    "    result_values = {'total_cost': total_cost.value, **{f'facility_{facility}': assignment.value for facility, assignment in enumerate(facility_assignments)}}\n",
    "\n",
    "    lines = [\n",
    "        f\"Facilities = {nb_max_facilities}; Sites = {nb_sites}; \"\n",
    "        f\"Total cost = {result_values['total_cost']}; Status = {solution.feasible}\"\n",
    "    ]\n",
    "    open_facilities = 0\n",
    "    for f in range(nb_max_facilities):\n",
    "        sites = result_values[f\"facility_{f}\"]\n",
    "        if sites:\n",
    "            open_facilities += 1\n",
    "            lines.append(f\"Facility {f} -> sites {sorted(sites)}\")\n",
    "    lines.append(f\"Open facilities: {open_facilities}/{nb_max_facilities}\")\n",
    "    result_text = \"\\n\".join(lines)\n",
    "    print(result_text)\n",
    "    if output_file is not None:\n",
    "        Path(output_file).write_text(result_text + \"\\n\", encoding=\"utf-8\")\n",
    "    return solution\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cflp-runs",
   "metadata": {},
   "source": [
    "## 本地运行\n",
    "\n",
    "Notebook 直接调用 `main` 并显式传入实例路径。以下代码格相互独立，可以按需要单独运行；调整 `time_limit` 可以控制每个实例的求解时间。\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cflp-instance-dir",
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "INSTANCE_DIR = Path.cwd() / \"instances\"\n",
    "print(\"Instances:\", INSTANCE_DIR)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cflp-run-cap61",
   "metadata": {},
   "outputs": [],
   "source": [
    "solution_cap61 = main(INSTANCE_DIR / \"cap61\", time_limit=1)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cflp-run-cap62",
   "metadata": {},
   "outputs": [],
   "source": [
    "solution_cap62 = main(INSTANCE_DIR / \"cap62\", time_limit=1)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cflp-run-cap63",
   "metadata": {},
   "outputs": [],
   "source": [
    "solution_cap63 = main(INSTANCE_DIR / \"cap63\", time_limit=1)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7cf3dd9e",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": ".venv",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.14.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
