|
422 | 422 | "\n", |
423 | 423 | "Sometimes you need constraints beyond what's built into the components. The `before_solve` callback lets you add custom constraints directly to the optimization model.\n", |
424 | 424 | "\n", |
425 | | - "### Example: Limit Main Boiler Startups\n", |
| 425 | + "### Example: Ramp Rate Limits\n", |
426 | 426 | "\n", |
427 | | - "Let's limit the main boiler to a maximum of 3 startups:" |
| 427 | + "Large boilers can't change output instantly—thermal stress limits how fast they can ramp up or down. Let's add a constraint limiting the main boiler to ±50 kW change per timestep:" |
428 | 428 | ] |
429 | 429 | }, |
430 | 430 | { |
|
434 | 434 | "metadata": {}, |
435 | 435 | "outputs": [], |
436 | 436 | "source": [ |
437 | | - "# Build a fresh system with status tracking on both boilers\n", |
438 | | - "fs_custom = flow_system.copy()\n", |
439 | | - "fs_custom.reset()\n", |
| 437 | + "fs_ramp = flow_system.copy()\n", |
440 | 438 | "\n", |
441 | 439 | "\n", |
442 | | - "def limit_main_boiler_startups(fs):\n", |
443 | | - " \"\"\"Custom constraint: Main boiler can start at most 3 times.\"\"\"\n", |
| 440 | + "def add_ramp_rate_limit(fs, max_ramp: float = 10):\n", |
| 441 | + " \"\"\"Limit ramp rate when boiler stays on. Uses Big-M to allow on/off jumps.\"\"\"\n", |
444 | 442 | " model = fs.model\n", |
445 | | - " main_startups = model.variables['MainBoiler|startup']\n", |
446 | | - " model.add_constraints(main_startups.sum() <= 3, name='max_main_startups')\n", |
| 443 | + " flow = model.variables['MainBoiler(Steam)|flow_rate']\n", |
| 444 | + " status = model.variables['MainBoiler|status']\n", |
447 | 445 | "\n", |
| 446 | + " ramp = flow - flow.shift(time=1)\n", |
| 447 | + " both_on = status + status.shift(time=1) # =2 when both on, <2 otherwise\n", |
448 | 448 | "\n", |
449 | | - "fs_custom.optimize(fx.solvers.HighsSolver(mip_gap=0.01), before_solve=limit_main_boiler_startups);" |
| 449 | + " big_m = 500 # Big-M (larger than max flow)\n", |
| 450 | + " model.add_constraints(ramp <= max_ramp + big_m * (2 - both_on), name='ramp_up')\n", |
| 451 | + " model.add_constraints(ramp >= -max_ramp - big_m * (2 - both_on), name='ramp_down')\n", |
| 452 | + "\n", |
| 453 | + "\n", |
| 454 | + "fs_ramp.optimize(fx.solvers.HighsSolver(mip_gap=0.01), before_solve=add_ramp_rate_limit);" |
450 | 455 | ] |
451 | 456 | }, |
452 | 457 | { |
|
456 | 461 | "metadata": {}, |
457 | 462 | "outputs": [], |
458 | 463 | "source": [ |
459 | | - "# Verify: Main boiler startups are limited\n", |
460 | | - "main_startups = int(fs_custom.solution['MainBoiler|startup'].sum().item())\n", |
461 | | - "print(f'MainBoiler startups: {main_startups} (limit: 3)')\n", |
462 | | - "\n", |
463 | | - "# Compare with unconstrained (which had more startups)\n", |
464 | | - "print(f'Original system startups: {total_startups}')\n", |
465 | | - "\n", |
466 | | - "# Show startup events\n", |
467 | | - "startup_ds = xr.Dataset({'MainBoiler startup': fs_custom.solution['MainBoiler|startup']})\n", |
468 | | - "startup_ds.plotly.line(x='time', title='Main Boiler Startup Events', height=250)" |
| 464 | + "# Compare: with vs without ramp rate limits\n", |
| 465 | + "comparison_ds = xr.Dataset(\n", |
| 466 | + " {\n", |
| 467 | + " 'Without ramp limit': flow_system.solution['MainBoiler(Steam)|flow_rate'],\n", |
| 468 | + " 'With ramp limit (±10 kW)': fs_ramp.solution['MainBoiler(Steam)|flow_rate'],\n", |
| 469 | + " }\n", |
| 470 | + ")\n", |
| 471 | + "comparison_ds.plotly.line(x='time', title='Main Boiler Output: Effect of Ramp Rate Limits', height=350)" |
469 | 472 | ] |
470 | 473 | }, |
471 | 474 | { |
|
484 | 487 | "id": "32", |
485 | 488 | "metadata": {}, |
486 | 489 | "outputs": [], |
| 490 | + "source": [ |
| 491 | + "# Calculate actual ramp rates (change between timesteps)\n", |
| 492 | + "flow_original = flow_system.solution['MainBoiler(Steam)|flow_rate']\n", |
| 493 | + "flow_ramp = fs_ramp.solution['MainBoiler(Steam)|flow_rate']\n", |
| 494 | + "\n", |
| 495 | + "ramp_original = flow_original.diff('time')\n", |
| 496 | + "ramp_limited = flow_ramp.diff('time')\n", |
| 497 | + "\n", |
| 498 | + "print(f'Without ramp limit: max ramp = {abs(ramp_original).max().item():.1f} kW/step')\n", |
| 499 | + "print(f'With ramp limit: max ramp = {abs(ramp_limited).max().item():.1f} kW/step (limit: 50 kW)')\n", |
| 500 | + "\n", |
| 501 | + "# Show the ramp rates over time\n", |
| 502 | + "ramp_ds = xr.Dataset(\n", |
| 503 | + " {\n", |
| 504 | + " 'Original ramp rate': ramp_original,\n", |
| 505 | + " 'Limited ramp rate': ramp_limited,\n", |
| 506 | + " }\n", |
| 507 | + ")\n", |
| 508 | + "ramp_ds.plotly.line(x='time', title='Ramp Rates (kW change per timestep)', height=300)" |
| 509 | + ] |
| 510 | + }, |
| 511 | + { |
| 512 | + "cell_type": "markdown", |
| 513 | + "id": "33", |
| 514 | + "metadata": {}, |
| 515 | + "source": [ |
| 516 | + "### Finding Available Variables\n", |
| 517 | + "\n", |
| 518 | + "To discover what variables you can use in custom constraints, inspect the model after building:" |
| 519 | + ] |
| 520 | + }, |
| 521 | + { |
| 522 | + "cell_type": "code", |
| 523 | + "execution_count": null, |
| 524 | + "id": "34", |
| 525 | + "metadata": {}, |
| 526 | + "outputs": [], |
487 | 527 | "source": [ |
488 | 528 | "# List all variables in the model\n", |
489 | 529 | "print('Available variables:')\n", |
490 | | - "for name in fs_custom.model.variables:\n", |
| 530 | + "for name in fs_ramp.model.variables:\n", |
491 | 531 | " print(f' {name}')" |
492 | 532 | ] |
493 | 533 | }, |
494 | 534 | { |
495 | 535 | "cell_type": "markdown", |
496 | | - "id": "33", |
| 536 | + "id": "35", |
497 | 537 | "metadata": {}, |
498 | 538 | "source": [ |
499 | 539 | "## Key Concepts\n", |
|
0 commit comments