EPANET Input File Export - Implementation Plan
Version: 1.0 Date: October 17, 2025 Status: Ready for Implementation Design Doc: scenario_inp_export_design.md
📋 Implementation Phases
Phase 1: Backend Core (EpanetConsoleCore) ⏱️ 3-4 hours
Phase 2: API Layer (DWD API) ⏱️ 2-3 hours
Phase 3: Frontend (React) ⏱️ 2-3 hours
Phase 4: Testing & Documentation ⏱️ 2 hours
Total Estimated Time: 9-12 hours
Phase 1: Backend Core (EpanetConsoleCore)
File Changes Required
- ✏️
epanetconsolecore/EpanetConsoleCore/Program.cs(MODIFY) - 📁
epanetconsolecore/EpanetConsoleCore/exports/.gitkeep(NEW) - ✏️
epanetconsolecore/.gitignore(MODIFY - add exports/)
1.1 Program.cs - Argument Parsing
Location: epanetconsolecore/EpanetConsoleCore/Program.cs
Existing Code (Line ~55-70):
public static async Task Main(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("Usage: EpanetConsoleCore <connection-string> <scenario-id>");
Console.WriteLine("Please provide a valid database connection string as the first argument and scenario ID as the second argument.");
return;
}
string pgConnStr = args[0];
if (!int.TryParse(args[1], out scenarioId))
{
Console.WriteLine("Please provide a valid scenario ID as the second argument.");
return;
}
logger = HTLogger.HTLogger.GetDefaultLogger();
logger.LogInformation($"Starting scenario {scenarioId}...");
try
{
await dwdRun(pgConnStr);
}
// ... rest of existing code
}
Modified Code:
public static async Task Main(string[] args)
{
// Parse command-line arguments
if (args.Length < 2)
{
Console.WriteLine("Usage: EpanetConsoleCore <connection-string> <scenario-id> [--export-only]");
Console.WriteLine(" <connection-string> Database connection string");
Console.WriteLine(" <scenario-id> Scenario ID to run or export");
Console.WriteLine(" --export-only Export INP file without running simulation");
return;
}
string pgConnStr = args[0];
if (!int.TryParse(args[1], out scenarioId))
{
Console.WriteLine("ERROR: Invalid scenario ID. Please provide a valid integer.");
Environment.Exit(1);
return;
}
// Check for export-only flag
bool exportOnly = args.Length >= 3 &&
(args[2] == "--export-only" || args[2] == "--export");
logger = HTLogger.HTLogger.GetDefaultLogger();
if (exportOnly)
{
logger.LogInformation($"Exporting scenario {scenarioId} to INP file...");
try
{
await ExportScenarioInputFile(pgConnStr, scenarioId);
}
catch (Exception ex)
{
logger.LogError($"Export failed: {ex.Message}");
Console.WriteLine($"EXPORT_ERROR:1:{ex.Message}");
Environment.Exit(1);
}
}
else
{
logger.LogInformation($"Starting scenario {scenarioId}...");
try
{
await dwdRun(pgConnStr);
}
catch (Exception ex)
{
logger.LogError($"Error executing scenario: {ex.Message}");
// ... existing error handling
}
}
}
1.2 Program.cs - New ExportScenarioInputFile Method
Location: epanetconsolecore/EpanetConsoleCore/Program.cs
Add New Method (after dwdRun method):
/// <summary>
/// Exports the scenario to an EPANET input file without running simulation.
/// Applies all database modifications and saves the resulting network.
/// </summary>
/// <param name="pgConnStr">PostgreSQL connection string</param>
/// <param name="scenarioId">Scenario ID to export</param>
private static async Task ExportScenarioInputFile(string pgConnStr, int scenarioId)
{
EpanetWrapper localEpanetWrapper = null;
EN2PostgresContext localContext = null;
try
{
// Initialize database context
var options = new DbContextOptionsBuilder<EN2PostgresContext>()
.UseNpgsql(pgConnStr)
.Options;
localContext = new EN2PostgresContext(options);
logger.LogInformation($"Loading scenario {scenarioId} from database...");
// Load scenario with options
var scenario = await localContext.Scenarios
.Include(s => s.Options)
.FirstOrDefaultAsync(s => s.Id == scenarioId);
if (scenario == null)
{
throw new Exception($"Scenario {scenarioId} not found in database");
}
// Validate base input file exists
if (string.IsNullOrEmpty(scenario.InputFile))
{
throw new Exception($"Scenario {scenarioId} has no base input file specified");
}
if (!File.Exists(scenario.InputFile))
{
throw new Exception($"Base input file not found: {scenario.InputFile}");
}
logger.LogInformation($"Base input file: {scenario.InputFile}");
// Get options (use scenario's options or default)
var scenarioOptions = scenario.Options;
if (scenarioOptions == null)
{
scenarioOptions = await localContext.Options.FirstOrDefaultAsync(o => o.IsDefault)
?? await localContext.Options.FirstOrDefaultAsync();
if (scenarioOptions == null)
{
throw new Exception("No options configuration found");
}
}
// Initialize EPANET wrapper
var filenameBase = Path.GetFileNameWithoutExtension(scenario.InputFile);
var inpBase = Path.Combine(Path.GetDirectoryName(scenario.InputFile), filenameBase);
localEpanetWrapper = new EpanetWrapper(
scenario.InputFile,
$"{inpBase}_export.rpt",
$"{inpBase}_export.out",
logger
);
logger.LogInformation("Opening EPANET project...");
// Open EPANET project
bool startupSuccess = localEpanetWrapper.Startup(
false, // doQuality - not needed for export
null, // msxFile - not needed for export
out int epanetErrorCode,
out string epanetErrorMessage
);
if (!startupSuccess)
{
throw new Exception($"EPANET startup failed (code {epanetErrorCode}): {epanetErrorMessage}");
}
logger.LogInformation("Applying database modifications...");
// Apply all database modifications
var syncService = new EpanetConsoleCore.Services.NetworkSynchronizationService(
localEpanetWrapper,
localContext,
logger
);
await syncService.SynchronizeFromDatabase(scenarioId);
logger.LogInformation("Database synchronization complete");
// Generate output file path
var exportDir = Path.Combine(
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location),
"exports"
);
Directory.CreateDirectory(exportDir);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var outputFileName = $"scenario_{scenarioId}_{timestamp}.inp";
var outputFilePath = Path.Combine(exportDir, outputFileName);
logger.LogInformation($"Saving to: {outputFilePath}");
// Save the modified network
int saveResult = localEpanetWrapper.EN_saveinpfile(outputFilePath);
if (saveResult != 0)
{
throw new Exception($"EN_saveinpfile failed with error code {saveResult}");
}
// Verify file was created
if (!File.Exists(outputFilePath))
{
throw new Exception("Output file was not created");
}
var fileInfo = new FileInfo(outputFilePath);
logger.LogInformation($"Export successful. File size: {fileInfo.Length} bytes");
// Output success message to stdout for API to capture
Console.WriteLine($"EXPORT_SUCCESS:{outputFilePath}");
Environment.Exit(0);
}
catch (Exception ex)
{
logger.LogError($"Export failed: {ex.Message}", ex);
Console.WriteLine($"EXPORT_ERROR:1:{ex.Message}");
Environment.Exit(1);
}
finally
{
// Clean up resources
localEpanetWrapper?.Dispose();
if (localContext != null)
{
await localContext.DisposeAsync();
}
}
}
1.3 Create exports directory
Location: epanetconsolecore/EpanetConsoleCore/exports/.gitkeep
Create empty file to ensure directory exists in git
Update .gitignore:
# Existing entries...
# Export temporary files
exports/*.inp
exports/*.rpt
exports/*.out
Phase 2: API Layer (DWD API)
File Changes Required
- ✏️
dwd-api-aspnet/DwdApiAspNet/Controllers/ScenarioController.cs(MODIFY)
2.1 ScenarioController.cs - New Download Endpoint
Location: dwd-api-aspnet/DwdApiAspNet/Controllers/ScenarioController.cs
Add after the StartScenario endpoint (around line 448):
// GET: api/Scenario/download/5
/// <summary>
/// Downloads the EPANET input file for a scenario with all database modifications applied.
/// </summary>
/// <param name="id">Scenario ID</param>
/// <returns>EPANET input file (.inp) as file download</returns>
[HttpGet("download/{id}")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> DownloadScenarioInputFile(int id)
{
#if DEBUG
LogRequest("DownloadScenarioInputFile", new { id });
#endif
try
{
// Validate scenario exists
var scenario = await _context.Scenarios.FindAsync(id);
if (scenario == null)
{
_logger.LogWarning("Scenario {Id} not found for download", id);
return NotFound(new { Message = $"Scenario with ID {id} not found" });
}
_logger.LogInformation("Starting export for scenario {Id} - {Name}", id, scenario.Name);
// Prepare process to export the INP file
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _epanetConsoleCorePath,
Arguments = $"\"{_connectionString}\" {id} --export-only",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(_epanetConsoleCorePath)
}
};
var output = new System.Text.StringBuilder();
var errors = new System.Text.StringBuilder();
process.OutputDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
output.AppendLine(e.Data);
_logger.LogDebug($"EpanetConsoleCore Output: {e.Data}");
}
};
process.ErrorDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
errors.AppendLine(e.Data);
_logger.LogError($"EpanetConsoleCore Error: {e.Data}");
}
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
// Wait for process with timeout (30 seconds)
var timeout = TimeSpan.FromSeconds(30);
bool completed = await Task.Run(() => process.WaitForExit((int)timeout.TotalMilliseconds));
if (!completed)
{
try
{
process.Kill();
_logger.LogError("Export process timed out for scenario {Id}", id);
}
catch (Exception killEx)
{
_logger.LogError(killEx, "Failed to kill timed-out process");
}
return StatusCode(500, new
{
Message = "Export operation timed out",
Error = "The export took longer than expected. Please try again or contact support."
});
}
// Parse output to find file path
var outputText = output.ToString();
var errorText = errors.ToString();
if (outputText.Contains("EXPORT_SUCCESS:"))
{
// Extract file path from output
var lines = outputText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var successLine = lines.FirstOrDefault(l => l.StartsWith("EXPORT_SUCCESS:"));
if (successLine == null)
{
_logger.LogError("EXPORT_SUCCESS found but could not parse file path");
return StatusCode(500, new { Message = "Export completed but file path not found" });
}
var filePath = successLine.Substring("EXPORT_SUCCESS:".Length).Trim();
if (!System.IO.File.Exists(filePath))
{
_logger.LogError("Export file not found: {FilePath}", filePath);
return StatusCode(500, new { Message = "Export file was not created" });
}
try
{
// Read file into memory
var fileBytes = await System.IO.File.ReadAllBytesAsync(filePath);
var fileInfo = new FileInfo(filePath);
_logger.LogInformation(
"Export successful for scenario {Id}. File size: {Size} bytes",
id,
fileInfo.Length
);
// Generate sanitized filename
var sanitizedName = SanitizeFileName(scenario.Name);
var fileName = $"{sanitizedName}_scenario_{id}.inp";
// Delete temporary file
try
{
System.IO.File.Delete(filePath);
_logger.LogDebug("Cleaned up temporary file: {FilePath}", filePath);
}
catch (Exception cleanupEx)
{
_logger.LogWarning(cleanupEx, "Failed to delete temporary file: {FilePath}", filePath);
// Don't fail the request if cleanup fails
}
// Return file to browser
return File(fileBytes, "text/plain", fileName);
}
catch (Exception fileEx)
{
_logger.LogError(fileEx, "Error reading or returning export file");
return StatusCode(500, new
{
Message = "Failed to read export file",
Error = fileEx.Message
});
}
}
else if (outputText.Contains("EXPORT_ERROR:"))
{
_logger.LogError("Export failed for scenario {Id}: {Error}", id, errorText);
return StatusCode(500, new
{
Message = "Export operation failed",
Error = errorText,
Details = outputText
});
}
else
{
_logger.LogError("Unexpected export output for scenario {Id}. Exit code: {ExitCode}", id, process.ExitCode);
return StatusCode(500, new
{
Message = "Export operation failed with unexpected output",
ExitCode = process.ExitCode,
Output = outputText,
Error = errorText
});
}
}
catch (Exception ex)
{
return HandleError(ex, "DownloadScenarioInputFile");
}
}
/// <summary>
/// Sanitizes a filename by removing/replacing invalid characters
/// </summary>
private string SanitizeFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return "scenario";
}
// Get invalid characters for filename
var invalidChars = Path.GetInvalidFileNameChars();
// Replace invalid characters with underscore
var sanitized = new string(fileName.Select(c =>
invalidChars.Contains(c) ? '_' : c
).ToArray());
// Replace multiple underscores with single underscore
while (sanitized.Contains("__"))
{
sanitized = sanitized.Replace("__", "_");
}
// Trim underscores from start and end
sanitized = sanitized.Trim('_');
// Limit length (keep it reasonable for filenames)
const int maxLength = 50;
if (sanitized.Length > maxLength)
{
sanitized = sanitized.Substring(0, maxLength).TrimEnd('_');
}
// If result is empty, use default
if (string.IsNullOrWhiteSpace(sanitized))
{
return "scenario";
}
return sanitized;
}
Add using statement at top of file:
using System.Linq;
Phase 3: Frontend (React)
File Changes Required
- ✏️
dwd-frontend-react/src/components/scenarios/ScenariosTable.jsx(MODIFY) - ✏️
dwd-frontend-react/src/components/scenarios/ScenarioDetailsDialog.jsx(MODIFY) - ✏️
dwd-frontend-react/src/pages/ScenariosList.jsx(MODIFY)
3.1 ScenariosTable.jsx - Add Download Button
Location: dwd-frontend-react/src/components/scenarios/ScenariosTable.jsx
Step 1: Add import for Download icon
Find the imports section and add:
import { Download as DownloadIcon } from '@mui/icons-material';
Step 2: Add state for download tracking
Find where props are destructured and add new prop:
const ScenariosTable = ({
scenarios,
isLoading,
selectedScenarioId,
onScenarioSelect,
onStartScenario,
onViewDetails,
onEditScenario,
onDeleteScenario,
onDownloadScenario, // NEW
downloadingScenarioId, // NEW
onRowClick,
onRowDoubleClick,
onRowMouseDown,
onRowMouseUp,
onRowMouseLeave
}) => {
Step 3: Find the Actions column in TableBody (around line 140-200)
Add Download button to actions cell:
<TableCell align="right">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Tooltip title="Start Scenario">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onStartScenario(scenario);
}}
disabled={scenario.status === 'Running'}
>
<PlayArrowIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* NEW: Download Button */}
<Tooltip title="Download INP File">
<span>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onDownloadScenario(scenario);
}}
disabled={downloadingScenarioId === scenario.id}
>
{downloadingScenarioId === scenario.id ? (
<CircularProgress size={20} />
) : (
<DownloadIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Tooltip title="View Details">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onViewDetails(scenario);
}}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* ... existing Edit and Delete buttons ... */}
</Box>
</TableCell>
Step 4: Add CircularProgress import
import { CircularProgress } from '@mui/material';
3.2 ScenariosList.jsx - Add Download Handler
Location: dwd-frontend-react/src/pages/ScenariosList.jsx
Step 1: Add state for download tracking (around line 26)
const [openDialog, setOpenDialog] = useState(false);
const [downloadingScenarioId, setDownloadingScenarioId] = useState(null); // NEW
Step 2: Add download handler function (around line 298)
const handleDownloadScenario = async (scenario) => {
try {
setDownloadingScenarioId(scenario.id);
const response = await fetch(
`${API_BASE_URL}/api/Scenario/download/${scenario.id}`,
{
method: 'GET',
headers: {
'Accept': 'text/plain'
}
}
);
if (!response.ok) {
let errorMessage = 'Download failed';
try {
const errorData = await response.json();
errorMessage = errorData.Message || errorData.Error || errorMessage;
} catch {
errorMessage = `Download failed with status ${response.status}`;
}
throw new Error(errorMessage);
}
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `scenario_${scenario.id}.inp`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
}
}
// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
console.log(`Downloaded: ${filename}`);
// TODO: Show success notification if notification system exists
} catch (error) {
console.error('Error downloading scenario:', error);
alert(`Failed to download scenario: ${error.message}`);
} finally {
setDownloadingScenarioId(null);
}
};
Step 3: Pass props to ScenariosTable (around line 319)
<ScenariosTable
scenarios={availableScenarios}
isLoading={isLoadingScenarios}
selectedScenarioId={selectedScenarioId}
onScenarioSelect={setSelectedScenarioId}
onStartScenario={handleStartScenario}
onViewDetails={handleViewDetails}
onEditScenario={handleOpenDialog}
onDeleteScenario={handleDelete}
onDownloadScenario={handleDownloadScenario} // NEW
downloadingScenarioId={downloadingScenarioId} // NEW
onRowClick={handleRowClick}
onRowDoubleClick={handleRowDoubleClick}
onRowMouseDown={handleRowMouseDown}
onRowMouseUp={handleRowMouseUp}
onRowMouseLeave={handleRowMouseLeave}
/>
3.3 ScenarioDetailsDialog.jsx - Add Download Button
Location: dwd-frontend-react/src/components/scenarios/ScenarioDetailsDialog.jsx
Step 1: Add import for Download icon and CircularProgress
import { Download as DownloadIcon } from '@mui/icons-material';
import { CircularProgress } from '@mui/material';
Step 2: Add props (find the component declaration)
const ScenarioDetailsDialog = ({
open,
onClose,
scenario,
onEdit,
onDelete,
onDownload, // NEW
downloadingScenarioId, // NEW
onEditControl,
onDeleteControl
}) => {
Step 3: Find the dialog actions section (usually at bottom of dialog content)
Add Download button to actions:
<DialogActions>
{/* Existing buttons */}
<Button onClick={() => onEdit(scenario)} startIcon={<EditIcon />}>
Edit
</Button>
<Button onClick={() => onDelete(scenario)} color="error" startIcon={<DeleteIcon />}>
Delete
</Button>
{/* NEW: Download Button */}
<Button
onClick={() => onDownload(scenario)}
startIcon={downloadingScenarioId === scenario?.id ?
<CircularProgress size={20} /> :
<DownloadIcon />
}
disabled={downloadingScenarioId === scenario?.id}
>
{downloadingScenarioId === scenario?.id ? 'Downloading...' : 'Download INP'}
</Button>
<Button onClick={onClose}>Close</Button>
</DialogActions>
Step 4: Update ScenariosList.jsx to pass props to dialog (around line 337)
<ScenarioDetailsDialog
open={openDetailsDialog}
onClose={() => setOpenDetailsDialog(false)}
scenario={viewingScenario}
onEdit={handleEditFromDetails}
onDelete={handleDelete}
onDownload={handleDownloadScenario} // NEW
downloadingScenarioId={downloadingScenarioId} // NEW
onEditControl={handleEditControl}
onDeleteControl={handleDeleteControl}
/>
Phase 4: Testing & Documentation
4.1 Manual Testing Checklist
Create test scenarios:
-- Test Scenario 1: Simple scenario with no modifications
INSERT INTO scenarios (name, description, input_file)
VALUES ('Test Simple Export', 'Basic export test', '/path/to/base.inp');
-- Test Scenario 2: Scenario with property overrides
-- (Create scenario with node/link property modifications)
-- Test Scenario 3: Scenario with special characters in name
INSERT INTO scenarios (name, description, input_file)
VALUES ('Test! @#$ Export%', 'Special chars test', '/path/to/base.inp');
Test Cases:
- Download from scenarios table
- Download from scenario details dialog
- Download shows loading indicator
- Download completes successfully
- Downloaded file has correct name
- Downloaded file is valid EPANET INP format
- Downloaded file contains database modifications
- Error handling: non-existent scenario
- Error handling: missing base INP file
- Error handling: EPANET error
- Concurrent downloads work
- No temp files left on disk
- Special characters in scenario name handled
4.2 Validation
Validate Downloaded File:
# Open in EPANET to verify it's valid
epanet2 scenario_5.inp
# Or use CLI validation
epanetcli validate scenario_5.inp
Check for modifications:
# Compare to base file
diff base.inp scenario_5.inp
# Should see database modifications in output
4.3 Documentation Updates
API Documentation:
Add to dwd-api-aspnet/API-Testing-README.md:
### Download Scenario INP File
**Endpoint:** `GET /api/Scenario/download/{id}`
**Description:** Downloads the EPANET input file for a scenario with all database modifications applied.
**Parameters:**
- `id` (path, integer, required): Scenario ID
**Response:**
- **200 OK**: File download
- Content-Type: `text/plain`
- Content-Disposition: `attachment; filename="<scenario-name>_scenario_<id>.inp"`
- **404 Not Found**: Scenario doesn't exist
- **500 Internal Server Error**: Export failed
**Example:**
```bash
curl -OJ http://localhost:5000/api/Scenario/download/5
**User Documentation:**
Add to project README or user guide:
```markdown
## Exporting Scenarios
To download the complete EPANET input file for a scenario:
1. Navigate to the Scenarios page
2. Find your scenario in the list
3. Click the Download icon (⬇️) in the Actions column
4. The `.inp` file will download to your browser's download folder
The exported file includes all modifications from:
- Node and link properties
- Custom options
- Simple and rule-based controls
- Patterns and curves
- Energy settings
- Reaction settings
You can open this file in EPANET or share it with colleagues.
4.4 Update .gitignore
Location: epanetconsolecore/.gitignore
Add:
# Export temporary files
exports/*.inp
exports/*.rpt
exports/*.out
!exports/.gitkeep
Location: dwd-api-aspnet/.gitignore
Ensure exports directory is ignored if created there as well.
Implementation Order
Recommended Sequence:
Start with Backend Core (Phase 1)
- Easiest to test independently
- Can test via command line before API integration
- Foundation for everything else
Add API Endpoint (Phase 2)
- Test with Postman/curl before frontend
- Verify file download works
Add Frontend UI (Phase 3)
- Add buttons and handlers
- Test end-to-end flow
Testing & Polish (Phase 4)
- Comprehensive testing
- Fix bugs
- Update documentation
Testing at Each Phase
After Phase 1 (Backend Core):
cd epanetconsolecore/EpanetConsoleCore
dotnet run -- "connection-string" 5 --export-only
# Expected output:
# EXPORT_SUCCESS:C:\path\to\exports\scenario_5_20251017_143522.inp
# Verify file exists and is valid EPANET INP
After Phase 2 (API):
# Test with curl
curl -OJ http://localhost:5000/api/Scenario/download/5
# Or Postman: GET http://localhost:5000/api/Scenario/download/5
After Phase 3 (Frontend):
# Run frontend dev server
cd dwd-frontend-react
npm start
# Click download button in browser
# Verify file downloads correctly
Rollback Plan
If issues are discovered after deployment:
Minimal Impact Rollback:
- Remove download button from frontend (UI-only change)
- API endpoint can remain (unused)
- No database changes to roll back
Full Rollback:
- Revert all code changes
- No data loss (read-only operation)
Success Criteria
- User can download INP file from UI
- Download completes in < 30 seconds
- File contains all database modifications
- No temp files accumulate on disk
- Error messages are clear
- Code passes review
- Documentation updated
Ready to start implementation! 🚀
Proceed with Phase 1 when ready.